Skip to content

Claim personal invitations #4953

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 6 commits into
base: devel
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/src/Entity/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ class Profile extends BaseEntity {
// addresses received from Oauth providers are trusted in the sense that email ownership has
// previously been verified by the corresponding service. When adding more providers, either
// - validate this assumption for the new provider, or
// - remove the logic setting the user state to active for existing non-activated user profiles
// in the new authenticator implementation (api/src/Security/OAuth/*Authenticator.php)
// - remove the logic setting the user state to active and claiming personal camp invitations
// for existing non-activated user profiles in the new authenticator implementation
// (api/src/Security/OAuth/*Authenticator.php)

/**
* Google id of the user.
Expand Down
7 changes: 7 additions & 0 deletions api/src/Repository/CampCollaborationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public function findByUserAndIdAndInvited(User $user, string $id): ?CampCollabor
return $this->findOneBy(['user' => $user, 'id' => $id, 'status' => CampCollaboration::STATUS_INVITED]);
}

/**
* @return CampCollaboration[]
*/
public function findAllByInviteEmailAndInvited(string $inviteEmail): array {
return $this->findBy(['inviteEmail' => $inviteEmail, 'status' => CampCollaboration::STATUS_INVITED]);
}

/**
* @return CampCollaboration[]
*/
Expand Down
10 changes: 10 additions & 0 deletions api/src/Security/OAuth/GoogleAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Entity\Profile;
use App\Entity\User;
use App\OAuth\JWTStateOAuth2Client;
use App\Service\ClaimInvitationService;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
Expand All @@ -30,6 +31,7 @@ public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private JWTEncoderInterface $jwtDecoder,
private ClaimInvitationService $claimInvitationService,
) {}

public function supports(Request $request): ?bool {
Expand Down Expand Up @@ -70,7 +72,9 @@ public function authenticate(Request $request): Passport {
$user->profile = $profile;
}

$newlyActivatedUser = false;
if (in_array($user->state, [null, User::STATE_NONREGISTERED, User::STATE_REGISTERED])) {
$newlyActivatedUser = true;
$user->state = User::STATE_ACTIVATED;
}

Expand All @@ -79,6 +83,12 @@ public function authenticate(Request $request): Passport {
$this->entityManager->persist($user);
$this->entityManager->flush();

if ($newlyActivatedUser) {
// Only after the user is persisted, claim invitations to make
// sure any errors during this process don't prevent user #
$this->claimInvitationService->claimInvitations($user, $email);
}

return $user;
})
);
Expand Down
10 changes: 10 additions & 0 deletions api/src/Security/OAuth/HitobitoAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Entity\User;
use App\OAuth\HitobitoUser;
use App\OAuth\JWTStateOAuth2Client;
use App\Service\ClaimInvitationService;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\OAuth2Client;
Expand All @@ -31,6 +32,7 @@ public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private JWTEncoderInterface $jwtDecoder,
private ClaimInvitationService $claimInvitationService,
) {}

public function supports(Request $request): ?bool {
Expand Down Expand Up @@ -77,7 +79,9 @@ public function authenticate(Request $request): Passport {
$user->profile = $profile;
}

$newlyActivatedUser = false;
if (in_array($user->state, [null, User::STATE_NONREGISTERED, User::STATE_REGISTERED])) {
$newlyActivatedUser = true;
$user->state = User::STATE_ACTIVATED;
}

Expand All @@ -86,6 +90,12 @@ public function authenticate(Request $request): Passport {
$this->entityManager->persist($user);
$this->entityManager->flush();

if ($newlyActivatedUser) {
// Only after the user is persisted, claim invitations to make
// sure any errors during this process don't prevent user #
$this->claimInvitationService->claimInvitations($user, $email);
}

return $user;
})
);
Expand Down
56 changes: 56 additions & 0 deletions api/src/Service/ClaimInvitationService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Service;

use App\Entity\User;
use App\Repository\CampCollaborationRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;

class ClaimInvitationService {
public function __construct(
private readonly CampCollaborationRepository $campCollaborationRepository,
private readonly EntityManagerInterface $em,
) {}

/**
* Convert all invitations who specifically invited this email address to
* personal invitations, which the invited user will be able to see and
* accept / reject in the UI, even without receiving the invitation email.
* This is done by setting the user field instead of the inviteEmail field.
*/
public function claimInvitations(User $user, string $email): void {
$personalInvitationsForNewEmail = $this->campCollaborationRepository->findAllByInviteEmailAndInvited($email);

foreach ($personalInvitationsForNewEmail as $invitation) {
if (null !== $this->campCollaborationRepository->findOneBy([
'user' => $user,
'camp' => $invitation->camp,
])) {
// If the user is already part of the camp, we skip claiming this invitation,
// because it would otherwise create a unique constraint violation, or we would
// need to merge the existing collaboration and the invitation, which would be
// very complex for an extremely rare use case which can easily be resolved by
// the camp leaders in the UI.
// We could also discard the invitation in this case, but previously, when users
// have had problems with receiving their invitation emails, we have recommended
// them to send an invitation to another email address and claim that other
// invitation. So this invitation could still be useful to someone. This way, we
// also minimize shenanigans with vanishing invitations, which could be
// confusing for the users.
continue;
}

try {
$invitation->inviteEmail = null;
$invitation->user = $user;
$this->em->persist($invitation);
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
// Even though we already handle this case above, it could still happen due
// to race conditions. Just ignore it.
$this->em->clear();
}
}
}
}
38 changes: 37 additions & 1 deletion api/src/State/ProfileUpdateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Profile;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Service\ClaimInvitationService;
use App\Service\MailService;
use App\State\Util\AbstractPersistProcessor;
use App\Util\IdGenerator;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
Expand All @@ -16,10 +23,15 @@
* @template-extends AbstractPersistProcessor<Profile>
*/
class ProfileUpdateProcessor extends AbstractPersistProcessor {
private $emailAddressVerificationPerformed = false;

public function __construct(
ProcessorInterface $decorated,
private PasswordHasherFactoryInterface $pwHasherFactory,
private MailService $mailService
private MailService $mailService,
private readonly Security $security,
private readonly UserRepository $userRepository,
private readonly ClaimInvitationService $claimInvitationService,
) {
parent::__construct($decorated);
}
Expand All @@ -28,6 +40,8 @@ public function __construct(
* @param Profile $data
*/
public function onBefore($data, Operation $operation, array $uriVariables = [], array $context = []): Profile {
$this->emailAddressVerificationPerformed = false;

/** @var Profile $data */
if (isset($data->newEmail)) {
$verificationKey = IdGenerator::generateRandomHexString(64);
Expand All @@ -47,6 +61,8 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],
$data->untrustedEmail = null;
$data->untrustedEmailKey = null;
$data->untrustedEmailKeyHash = null;

$this->emailAddressVerificationPerformed = true;
}

return $data;
Expand All @@ -58,9 +74,29 @@ public function onAfter($data, Operation $operation, array $uriVariables = [], a
$this->mailService->sendEmailVerificationMail($data->user, $data);
$data->untrustedEmailKey = null;
}

if ($this->emailAddressVerificationPerformed) {
$user = $this->getUser();
$this->claimInvitationService->claimInvitations($user, $data->email);
}
}

private function getResetKeyHasher(): PasswordHasherInterface {
return $this->pwHasherFactory->getPasswordHasher('EmailVerification');
}

/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
private function getUser(): ?User {
$user = $this->security->getUser();
if (null == $user) {
// This should never happen because it should be caught earlier by our security settings
// on all API operations using this processor.
throw new AccessDeniedHttpException();
}

return $this->userRepository->loadUserByIdentifier($user->getUserIdentifier());
}
}
10 changes: 9 additions & 1 deletion api/src/State/UserActivateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Service\ClaimInvitationService;
use App\State\Util\AbstractPersistProcessor;
use Symfony\Component\HttpKernel\Exception\HttpException;

Expand All @@ -13,7 +14,8 @@
*/
class UserActivateProcessor extends AbstractPersistProcessor {
public function __construct(
ProcessorInterface $decorated
ProcessorInterface $decorated,
private readonly ClaimInvitationService $claimInvitationService,
) {
parent::__construct($decorated);
}
Expand All @@ -32,4 +34,10 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],

return $data;
}

public function onAfter($data, Operation $operation, array $uriVariables = [], array $context = []): void {
/** @var User $user */
$user = $data;
$this->claimInvitationService->claimInvitations($user, $user->getEmail());
}
}
40 changes: 40 additions & 0 deletions api/tests/Api/PersonalInvitations/ListPersonalInvitationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Tests\Api\PersonalInvitations;

use App\Entity\User;
use App\Tests\Api\ECampApiTestCase;

/**
* @internal
*/
class ListPersonalInvitationsTest extends ECampApiTestCase {
public function testListPersonalInvitationsIsDeniedForAnonymousUser() {
static::createBasicClient()->request('GET', '/personal_invitations');
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains([
'code' => 401,
'message' => 'JWT Token not found',
]);
}

public function testListPersonalInvitationsIsAllowedForLoggedInUserButFiltered() {
/** @var User $user */
$user = static::getFixture('user6invited');
$client = static::createClientWithCredentials(['email' => $user->getProfile()->email]);
$client->request('GET', '/personal_invitations');
$this->assertResponseStatusCodeSame(200);
$invitation = static::getFixture('campCollaboration6invitedWithUser');
$this->assertJsonContains([
'totalItems' => 1,
'_links' => [
'items' => [
['href' => "/personal_invitations/{$invitation->getId()}"],
],
],
'_embedded' => [
'items' => [],
],
]);
}
}
Loading
Loading