diff --git a/config/packages/security.yaml b/config/packages/security.yaml index bba38be8..d6a3179d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -20,6 +20,8 @@ security: guard: authenticators: - Buddy\Repman\Security\TokenAuthenticator + - Buddy\Repman\Security\AnonymousOrganizationUserAuthenticator + entry_point: Buddy\Repman\Security\AnonymousOrganizationUserAuthenticator main: anonymous: lazy provider: user_provider @@ -45,6 +47,8 @@ security: - { path: ^/user, roles: ROLE_USER } - { path: ^/organization/new, roles: ROLE_USER } - { path: ^/$, roles: ROLE_USER } + - { path: ^/organization/.+/overview$, roles: ROLE_ORGANIZATION_ANONYMOUS_USER } + - { path: ^/organization/.+/package$, roles: ROLE_ORGANIZATION_ANONYMOUS_USER } - { path: ^/organization/.+(/.+)*, roles: ROLE_ORGANIZATION_MEMBER } - { path: ^/downloads, host: '([a-z0-9_-]+)\.repo\.(.+)', roles: IS_AUTHENTICATED_ANONYMOUSLY} - { path: ^/, host: '([a-z0-9_-]+)\.repo\.(.+)', roles: ROLE_ORGANIZATION } diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 6761a186..ad2b5e4f 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -5,10 +5,12 @@ namespace Buddy\Repman\Controller; use Buddy\Repman\Form\Type\Organization\ChangeAliasType; +use Buddy\Repman\Form\Type\Organization\ChangeAnonymousAccessType; use Buddy\Repman\Form\Type\Organization\ChangeNameType; use Buddy\Repman\Form\Type\Organization\CreateType; use Buddy\Repman\Form\Type\Organization\GenerateTokenType; use Buddy\Repman\Message\Organization\ChangeAlias; +use Buddy\Repman\Message\Organization\ChangeAnonymousAccess; use Buddy\Repman\Message\Organization\ChangeName; use Buddy\Repman\Message\Organization\CreateOrganization; use Buddy\Repman\Message\Organization\GenerateToken; @@ -97,7 +99,8 @@ public function overview(Organization $organization): Response public function packages(Organization $organization, Request $request): Response { $count = $this->packageQuery->count($organization->id()); - if ($count === 0 && $organization->isOwner($this->getUser()->id())) { + $user = parent::getUser(); + if ($count === 0 && $user instanceof User && $organization->isOwner($user->id())) { return $this->redirectToRoute('organization_package_new', ['organization' => $organization->alias()]); } @@ -287,10 +290,20 @@ public function settings(Organization $organization, Request $request): Response return $this->redirectToRoute('organization_settings', ['organization' => $aliasForm->get('alias')->getData()]); } + $anonymousAccessForm = $this->createForm(ChangeAnonymousAccessType::class, ['hasAnonymousAccess' => $organization->hasAnonymousAccess()]); + $anonymousAccessForm->handleRequest($request); + if ($anonymousAccessForm->isSubmitted() && $anonymousAccessForm->isValid()) { + $this->dispatchMessage(new ChangeAnonymousAccess($organization->id(), $anonymousAccessForm->get('hasAnonymousAccess')->getData())); + $this->addFlash('success', 'Anonymous access has been successfully changed.'); + + return $this->redirectToRoute('organization_settings', ['organization' => $organization->alias()]); + } + return $this->render('organization/settings.html.twig', [ 'organization' => $organization, 'renameForm' => $renameForm->createView(), 'aliasForm' => $aliasForm->createView(), + 'anonymousAccessForm' => $anonymousAccessForm->createView(), ]); } diff --git a/src/Entity/Organization.php b/src/Entity/Organization.php index d35dd2e0..49d1cd4d 100644 --- a/src/Entity/Organization.php +++ b/src/Entity/Organization.php @@ -65,6 +65,11 @@ class Organization */ private ?Collection $members = null; + /** + * @ORM\Column(type="boolean") + */ + private bool $hasAnonymousAccess = false; + public function __construct(UuidInterface $id, User $owner, string $name, string $alias) { $this->id = $id; @@ -246,6 +251,11 @@ public function members(): Collection return $this->members; } + public function changeAnonymousAccess(bool $hasAnonymousAccess): void + { + $this->hasAnonymousAccess = $hasAnonymousAccess; + } + private function isLastOwner(User $user): bool { $owners = $this->members->filter(fn (Member $member) => $member->isOwner()); diff --git a/src/Form/Type/Organization/ChangeAnonymousAccessType.php b/src/Form/Type/Organization/ChangeAnonymousAccessType.php new file mode 100644 index 00000000..322a0c10 --- /dev/null +++ b/src/Form/Type/Organization/ChangeAnonymousAccessType.php @@ -0,0 +1,32 @@ + $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('hasAnonymousAccess', CheckboxType::class, [ + 'label' => 'Allow anonymous users', + 'required' => false, + ]) + ->add('changeAnonymousAccess', SubmitType::class, ['label' => 'Change']) + ; + } +} diff --git a/src/Message/Organization/ChangeAnonymousAccess.php b/src/Message/Organization/ChangeAnonymousAccess.php new file mode 100644 index 00000000..0ab6e890 --- /dev/null +++ b/src/Message/Organization/ChangeAnonymousAccess.php @@ -0,0 +1,27 @@ +organizationId = $organizationId; + $this->hasAnonymousAccess = $hasAnonymousAccess; + } + + public function organizationId(): string + { + return $this->organizationId; + } + + public function hasAnonymousAccess(): bool + { + return $this->hasAnonymousAccess; + } +} diff --git a/src/MessageHandler/Organization/ChangeAnonymousAccessHandler.php b/src/MessageHandler/Organization/ChangeAnonymousAccessHandler.php new file mode 100644 index 00000000..e0aa1aa6 --- /dev/null +++ b/src/MessageHandler/Organization/ChangeAnonymousAccessHandler.php @@ -0,0 +1,28 @@ +repositories = $repositories; + } + + public function __invoke(ChangeAnonymousAccess $message): void + { + $this->repositories + ->getById(Uuid::fromString($message->organizationId())) + ->changeAnonymousAccess($message->hasAnonymousAccess()) + ; + } +} diff --git a/src/Migrations/Version20200615181216.php b/src/Migrations/Version20200615181216.php new file mode 100644 index 00000000..19c290a5 --- /dev/null +++ b/src/Migrations/Version20200615181216.php @@ -0,0 +1,37 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('ALTER TABLE organization ADD has_anonymous_access BOOLEAN NOT NULL DEFAULT false'); + $this->addSql('ALTER TABLE "user" ALTER email_scan_result DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('ALTER TABLE organization DROP has_anonymous_access'); + $this->addSql('ALTER TABLE "user" ALTER email_scan_result SET DEFAULT \'true\''); + } +} diff --git a/src/Query/User/Model/Organization.php b/src/Query/User/Model/Organization.php index 91caa1c0..6afd2638 100644 --- a/src/Query/User/Model/Organization.php +++ b/src/Query/User/Model/Organization.php @@ -12,6 +12,8 @@ final class Organization private string $id; private string $name; private string $alias; + private bool $hasAnonymousAccess; + /** * @var Member[] */ @@ -22,12 +24,13 @@ final class Organization /** * @param Member[] $members */ - public function __construct(string $id, string $name, string $alias, array $members, ?string $token = null) + public function __construct(string $id, string $name, string $alias, array $members, bool $hasAnonymousAccess, ?string $token = null) { $this->id = $id; $this->name = $name; $this->alias = $alias; $this->members = array_map(fn (Member $member) => $member, $members); + $this->hasAnonymousAccess = $hasAnonymousAccess; $this->token = $token; } @@ -93,4 +96,9 @@ public function getMember(string $userId): Option return Option::none(); } + + public function hasAnonymousAccess(): bool + { + return $this->hasAnonymousAccess; + } } diff --git a/src/Query/User/OrganizationQuery/DbalOrganizationQuery.php b/src/Query/User/OrganizationQuery/DbalOrganizationQuery.php index a2882eae..3c9934f4 100644 --- a/src/Query/User/OrganizationQuery/DbalOrganizationQuery.php +++ b/src/Query/User/OrganizationQuery/DbalOrganizationQuery.php @@ -28,7 +28,8 @@ public function __construct(Connection $connection) public function getByAlias(string $alias): Option { $data = $this->connection->fetchAssoc( - 'SELECT id, name, alias FROM "organization" WHERE alias = :alias', [ + 'SELECT id, name, alias, has_anonymous_access + FROM "organization" WHERE alias = :alias', [ ':alias' => $alias, ]); @@ -41,8 +42,9 @@ public function getByAlias(string $alias): Option public function getByInvitation(string $token, string $email): Option { - $data = $this->connection->fetchAssoc('SELECT o.id, o.name, o.alias - FROM "organization" o + $data = $this->connection->fetchAssoc( + 'SELECT o.id, o.name, o.alias, o.has_anonymous_access + FROM "organization" o JOIN organization_invitation i ON o.id = i.organization_id WHERE i.token = :token AND i.email = :email ', [ @@ -70,8 +72,8 @@ public function findAllTokens(string $organizationId, int $limit = 20, int $offs $data['last_used_at'] !== null ? new \DateTimeImmutable($data['last_used_at']) : null ); }, $this->connection->fetchAll(' - SELECT name, value, created_at, last_used_at - FROM organization_token + SELECT name, value, created_at, last_used_at + FROM organization_token WHERE organization_id = :id ORDER BY UPPER(name) ASC LIMIT :limit OFFSET :offset', [ @@ -149,7 +151,7 @@ public function findAllMembers(string $organizationId, int $limit = 20, int $off $row['role'] ); }, $this->connection->fetchAll(' - SELECT u.id, u.email, m.role + SELECT u.id, u.email, m.role FROM organization_member AS m JOIN "user" u ON u.id = m.user_id WHERE m.organization_id = :id @@ -214,6 +216,7 @@ private function hydrateOrganization(array $data): Organization $data['name'], $data['alias'], array_map(fn (array $row) => new Member($row['user_id'], $row['email'], $row['role']), $members), + $data['has_anonymous_access'], $token !== false ? $token : null ); } diff --git a/src/Security/AnonymousOrganizationUserAuthenticator.php b/src/Security/AnonymousOrganizationUserAuthenticator.php new file mode 100644 index 00000000..735dbeb7 --- /dev/null +++ b/src/Security/AnonymousOrganizationUserAuthenticator.php @@ -0,0 +1,81 @@ + 'Authentication Required', + ], Response::HTTP_UNAUTHORIZED); + } + + public function supports(Request $request) + { + return $request->get('_route') !== 'repo_package_downloads' + && !$request->headers->has('PHP_AUTH_USER') + && !$request->headers->has('PHP_AUTH_PW'); + } + + public function getCredentials(Request $request) + { + $organizationAlias = $request->get('organization'); + if ($organizationAlias === null) { + throw new BadCredentialsException(); + } + + return $organizationAlias; + } + + public function getUser($credentials, UserProviderInterface $userProvider) + { + if (!$userProvider instanceof OrganizationProvider) { + throw new \InvalidArgumentException(); + } + + return $userProvider->loadUserByAlias($credentials); + } + + public function checkCredentials($credentials, UserInterface $user) + { + return true; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + return new JsonResponse([ + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), + ], Response::HTTP_FORBIDDEN); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey) + { + return null; + } + + /** + * @codeCoverageIgnore + */ + public function supportsRememberMe(): bool + { + return false; + } +} diff --git a/src/Security/Model/User/Organization.php b/src/Security/Model/User/Organization.php index f7c80cec..f3b802f2 100644 --- a/src/Security/Model/User/Organization.php +++ b/src/Security/Model/User/Organization.php @@ -9,12 +9,14 @@ final class Organization private string $alias; private string $name; private string $role; + private bool $hasAnonymousAccess; - public function __construct(string $alias, string $name, string $role) + public function __construct(string $alias, string $name, string $role, bool $hasAnonymousAccess) { $this->alias = $alias; $this->name = $name; $this->role = $role; + $this->hasAnonymousAccess = $hasAnonymousAccess; } public function alias(): string @@ -31,4 +33,9 @@ public function role(): string { return $this->role; } + + public function hasAnonymousAccess(): bool + { + return $this->hasAnonymousAccess; + } } diff --git a/src/Security/OrganizationProvider.php b/src/Security/OrganizationProvider.php index 01aebe25..c5074aa7 100644 --- a/src/Security/OrganizationProvider.php +++ b/src/Security/OrganizationProvider.php @@ -49,6 +49,16 @@ public function supportsClass(string $class) return $class === Organization::class; } + public function loadUserByAlias(string $alias): Organization + { + $data = $this->getUserDataByAlias($alias); + if ($data === false) { + throw new BadCredentialsException(); + } + + return $this->hydrateOrganization($data); + } + private function updateLastUsed(string $token): void { $this->connection->executeQuery('UPDATE organization_token SET last_used_at = :now WHERE value = :value', [ @@ -63,14 +73,28 @@ private function updateLastUsed(string $token): void private function getUserDataByToken(string $token) { return $this->connection->fetchAssoc(' - SELECT t.value, o.name, o.alias, o.id FROM organization_token t - JOIN organization o ON o.id = t.organization_id + SELECT t.value, o.name, o.alias, o.id FROM organization_token t + JOIN organization o ON o.id = t.organization_id WHERE t.value = :token', [ ':token' => $token, ]); } + /** + * @return false|mixed[] + */ + private function getUserDataByAlias(string $alias) + { + return $this->connection->fetchAssoc(" + SELECT id, name, alias, 'anonymous' AS value + FROM organization + WHERE alias = :alias AND has_anonymous_access = true", + [ + ':alias' => $alias, + ]); + } + /** * @param mixed[] $data */ diff --git a/src/Security/TokenAuthenticator.php b/src/Security/TokenAuthenticator.php index 54388efb..e9a89dff 100644 --- a/src/Security/TokenAuthenticator.php +++ b/src/Security/TokenAuthenticator.php @@ -15,6 +15,11 @@ final class TokenAuthenticator extends AbstractGuardAuthenticator { + /** + * @codeCoverageIgnore + * + * @return Response + */ public function start(Request $request, AuthenticationException $authException = null) { $data = [ diff --git a/src/Security/UserProvider.php b/src/Security/UserProvider.php index 6679cf3b..9677d288 100644 --- a/src/Security/UserProvider.php +++ b/src/Security/UserProvider.php @@ -73,7 +73,7 @@ private function getUserDataByEmail(string $email) private function hydrateUser(array $data): User { $organizations = $this->connection->fetchAll(' - SELECT o.name, o.alias, om.role FROM organization_member om + SELECT o.name, o.alias, om.role, o.has_anonymous_access FROM organization_member om JOIN organization o ON o.id = om.organization_id WHERE om.user_id = :userId ORDER BY o.name ', ['userId' => $data['id']]); @@ -86,7 +86,7 @@ private function hydrateUser(array $data): User $data['email_confirmed_at'] !== null, $data['email_confirm_token'], json_decode($data['roles'], true), - array_map(fn (array $data) => new User\Organization($data['alias'], $data['name'], $data['role']), $organizations), + array_map(fn (array $data) => new User\Organization($data['alias'], $data['name'], $data['role'], $data['has_anonymous_access']), $organizations), $data['email_scan_result'], ); } diff --git a/src/Service/Organization/OrganizationAnonymousUserVoter.php b/src/Service/Organization/OrganizationAnonymousUserVoter.php new file mode 100644 index 00000000..58a232ad --- /dev/null +++ b/src/Service/Organization/OrganizationAnonymousUserVoter.php @@ -0,0 +1,59 @@ +organizations = $organizations; + } + + protected function supports(string $attribute, $subject): bool + { + return in_array($attribute, [ + 'ROLE_ORGANIZATION_ANONYMOUS_USER', + ], true); + } + + /** + * @param mixed $subject + * @param mixed[] $attributes + */ + public function vote(TokenInterface $token, $subject, array $attributes): int + { + $user = $token->getUser(); + if ($user instanceof User) { + return self::ACCESS_ABSTAIN; + } + + return parent::vote($token, $subject, $attributes); + } + + /** + * @param mixed|Request $subject + */ + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + { + $organization = $subject instanceof Request + ? $this->organizations->getByAlias($subject->get('organization'))->get() + : $subject; + + if ($organization instanceof Organization) { + return $organization->hasAnonymousAccess(); + } + + return false; + } +} diff --git a/src/Service/Organization/OrganizationVoter.php b/src/Service/Organization/OrganizationVoter.php index 901a9495..b594b43f 100644 --- a/src/Service/Organization/OrganizationVoter.php +++ b/src/Service/Organization/OrganizationVoter.php @@ -5,6 +5,7 @@ namespace Buddy\Repman\Service\Organization; use Buddy\Repman\Query\User\Model\Organization; +use Buddy\Repman\Query\User\OrganizationQuery; use Buddy\Repman\Security\Model\User; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -12,14 +13,35 @@ final class OrganizationVoter extends Voter { + private OrganizationQuery $organizations; + + public function __construct(OrganizationQuery $organizations) + { + $this->organizations = $organizations; + } + protected function supports(string $attribute, $subject): bool { return in_array($attribute, [ 'ROLE_ORGANIZATION_MEMBER', 'ROLE_ORGANIZATION_OWNER', + 'ROLE_ORGANIZATION_ANONYMOUS_USER', ], true); } + /** + * @param mixed $subject + * @param mixed[] $attributes + */ + public function vote(TokenInterface $token, $subject, array $attributes): int + { + if (!$token->getUser() instanceof User) { + return self::ACCESS_ABSTAIN; + } + + return parent::vote($token, $subject, $attributes); + } + /** * @param mixed|Request $subject */ diff --git a/templates/base.html.twig b/templates/base.html.twig index db6fdf30..af9d1c1a 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -57,18 +57,20 @@ Packages - - + {% if is_granted('ROLE_ORGANIZATION_MEMBER', organization) %} + + + {% endif %} {% if is_granted('ROLE_ORGANIZATION_OWNER', organization) %}