The start of something beautiful
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
/**
|
||||
* Passport badges allow to add more information to a passport (e.g. a CSRF token).
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
interface BadgeInterface
|
||||
{
|
||||
/**
|
||||
* Checks if this badge is resolved by the security system.
|
||||
*
|
||||
* After authentication, all badges must return `true` in this method in order
|
||||
* for the authentication to succeed.
|
||||
*/
|
||||
public function isResolved(): bool;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
use Symfony\Component\Security\Http\EventListener\CsrfProtectionListener;
|
||||
|
||||
/**
|
||||
* Adds automatic CSRF tokens checking capabilities to this authenticator.
|
||||
*
|
||||
* @see CsrfProtectionListener
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class CsrfTokenBadge implements BadgeInterface
|
||||
{
|
||||
private bool $resolved = false;
|
||||
private string $csrfTokenId;
|
||||
private ?string $csrfToken;
|
||||
|
||||
/**
|
||||
* @param string $csrfTokenId An arbitrary string used to generate the value of the CSRF token.
|
||||
* Using a different string for each authenticator improves its security.
|
||||
* @param string|null $csrfToken The CSRF token presented in the request, if any
|
||||
*/
|
||||
public function __construct(string $csrfTokenId, #[\SensitiveParameter] ?string $csrfToken)
|
||||
{
|
||||
$this->csrfTokenId = $csrfTokenId;
|
||||
$this->csrfToken = $csrfToken;
|
||||
}
|
||||
|
||||
public function getCsrfTokenId(): string
|
||||
{
|
||||
return $this->csrfTokenId;
|
||||
}
|
||||
|
||||
public function getCsrfToken(): ?string
|
||||
{
|
||||
return $this->csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function markResolved(): void
|
||||
{
|
||||
$this->resolved = true;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->resolved;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
|
||||
/**
|
||||
* Adds automatic password migration, if enabled and required in the password encoder.
|
||||
*
|
||||
* @see PasswordUpgraderInterface
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class PasswordUpgradeBadge implements BadgeInterface
|
||||
{
|
||||
private ?string $plaintextPassword = null;
|
||||
private ?PasswordUpgraderInterface $passwordUpgrader;
|
||||
|
||||
/**
|
||||
* @param string $plaintextPassword The presented password, used in the rehash
|
||||
* @param PasswordUpgraderInterface|null $passwordUpgrader The password upgrader, defaults to the UserProvider if null
|
||||
*/
|
||||
public function __construct(#[\SensitiveParameter] string $plaintextPassword, ?PasswordUpgraderInterface $passwordUpgrader = null)
|
||||
{
|
||||
$this->plaintextPassword = $plaintextPassword;
|
||||
$this->passwordUpgrader = $passwordUpgrader;
|
||||
}
|
||||
|
||||
public function getAndErasePlaintextPassword(): string
|
||||
{
|
||||
$password = $this->plaintextPassword;
|
||||
if (null === $password) {
|
||||
throw new LogicException('The password is erased as another listener already used this badge.');
|
||||
}
|
||||
|
||||
$this->plaintextPassword = null;
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
public function getPasswordUpgrader(): ?PasswordUpgraderInterface
|
||||
{
|
||||
return $this->passwordUpgrader;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractPreAuthenticatedAuthenticator;
|
||||
|
||||
/**
|
||||
* Marks the authentication as being pre-authenticated.
|
||||
*
|
||||
* This disables pre-authentication user checkers.
|
||||
*
|
||||
* @see AbstractPreAuthenticatedAuthenticator
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class PreAuthenticatedUserBadge implements BadgeInterface
|
||||
{
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
use Symfony\Component\Security\Http\EventListener\CheckRememberMeConditionsListener;
|
||||
|
||||
/**
|
||||
* Adds support for remember me to this authenticator.
|
||||
*
|
||||
* The presence of this badge doesn't create the remember-me cookie. The actual
|
||||
* cookie is only created if this badge is enabled. By default, this is done
|
||||
* by the {@see CheckRememberMeConditionsListener} if all conditions are met.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class RememberMeBadge implements BadgeInterface
|
||||
{
|
||||
private bool $enabled = false;
|
||||
|
||||
public function __construct(
|
||||
public readonly array $parameters = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables remember-me cookie creation.
|
||||
*
|
||||
* In most cases, {@see CheckRememberMeConditionsListener} enables this
|
||||
* automatically if always_remember_me is true or the remember_me_parameter
|
||||
* exists in the request.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function enable(): static
|
||||
{
|
||||
$this->enabled = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables remember-me cookie creation.
|
||||
*
|
||||
* The default is disabled, this can be called to suppress creation
|
||||
* after it was enabled.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function disable(): static
|
||||
{
|
||||
$this->enabled = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return true; // remember me does not need to be explicitly resolved
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Badge;
|
||||
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Http\EventListener\UserProviderListener;
|
||||
|
||||
/**
|
||||
* Represents the user in the authentication process.
|
||||
*
|
||||
* It uses an identifier (e.g. email, or username) and
|
||||
* "user loader" to load the related User object.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
class UserBadge implements BadgeInterface
|
||||
{
|
||||
public const MAX_USERNAME_LENGTH = 4096;
|
||||
|
||||
private string $userIdentifier;
|
||||
/** @var callable|null */
|
||||
private $userLoader;
|
||||
private UserInterface $user;
|
||||
private ?array $attributes;
|
||||
|
||||
/**
|
||||
* Initializes the user badge.
|
||||
*
|
||||
* You must provide a $userIdentifier. This is a unique string representing the
|
||||
* user for this authentication (e.g. the email if authentication is done using
|
||||
* email + password; or a string combining email+company if authentication is done
|
||||
* based on email *and* company name). This string can be used for e.g. login throttling.
|
||||
*
|
||||
* Optionally, you may pass a user loader. This callable receives the $userIdentifier
|
||||
* as argument and must return a UserInterface object (otherwise an AuthenticationServiceException
|
||||
* is thrown). If this is not set, the default user provider will be used with
|
||||
* $userIdentifier as username.
|
||||
*/
|
||||
public function __construct(string $userIdentifier, ?callable $userLoader = null, ?array $attributes = null)
|
||||
{
|
||||
if (\strlen($userIdentifier) > self::MAX_USERNAME_LENGTH) {
|
||||
throw new BadCredentialsException('Username too long.');
|
||||
}
|
||||
|
||||
$this->userIdentifier = $userIdentifier;
|
||||
$this->userLoader = $userLoader;
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->userIdentifier;
|
||||
}
|
||||
|
||||
public function getAttributes(): ?array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthenticationException when the user cannot be found
|
||||
*/
|
||||
public function getUser(): UserInterface
|
||||
{
|
||||
if (isset($this->user)) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if (null === $this->userLoader) {
|
||||
throw new \LogicException(sprintf('No user loader is configured, did you forget to register the "%s" listener?', UserProviderListener::class));
|
||||
}
|
||||
|
||||
if (null === $this->getAttributes()) {
|
||||
$user = ($this->userLoader)($this->userIdentifier);
|
||||
} else {
|
||||
$user = ($this->userLoader)($this->userIdentifier, $this->getAttributes());
|
||||
}
|
||||
|
||||
// No user has been found via the $this->userLoader callback
|
||||
if (null === $user) {
|
||||
$exception = new UserNotFoundException();
|
||||
$exception->setUserIdentifier($this->userIdentifier);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if (!$user instanceof UserInterface) {
|
||||
throw new AuthenticationServiceException(sprintf('The user provider must return a UserInterface object, "%s" given.', get_debug_type($user)));
|
||||
}
|
||||
|
||||
return $this->user = $user;
|
||||
}
|
||||
|
||||
public function getUserLoader(): ?callable
|
||||
{
|
||||
return $this->userLoader;
|
||||
}
|
||||
|
||||
public function setUserLoader(callable $userLoader): void
|
||||
{
|
||||
$this->userLoader = $userLoader;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Credentials;
|
||||
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
|
||||
/**
|
||||
* Credentials are a special badge used to explicitly mark the
|
||||
* credential check of an authenticator.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
interface CredentialsInterface extends BadgeInterface
|
||||
{
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Credentials;
|
||||
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Implements credentials checking using a custom checker function.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class CustomCredentials implements CredentialsInterface
|
||||
{
|
||||
private \Closure $customCredentialsChecker;
|
||||
private mixed $credentials;
|
||||
private bool $resolved = false;
|
||||
|
||||
/**
|
||||
* @param callable $customCredentialsChecker the check function. If this function does not return `true`, a
|
||||
* BadCredentialsException is thrown. You may also throw a more
|
||||
* specific exception in the function.
|
||||
*/
|
||||
public function __construct(callable $customCredentialsChecker, mixed $credentials)
|
||||
{
|
||||
$this->customCredentialsChecker = $customCredentialsChecker(...);
|
||||
$this->credentials = $credentials;
|
||||
}
|
||||
|
||||
public function executeCustomChecker(UserInterface $user): void
|
||||
{
|
||||
$checker = $this->customCredentialsChecker;
|
||||
|
||||
if (true !== $checker($this->credentials, $user)) {
|
||||
throw new BadCredentialsException('Credentials check failed as the callable passed to CustomCredentials did not return "true".');
|
||||
}
|
||||
|
||||
$this->resolved = true;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->resolved;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport\Credentials;
|
||||
|
||||
use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
|
||||
/**
|
||||
* Implements password credentials.
|
||||
*
|
||||
* These plaintext passwords are checked by the UserPasswordHasher during
|
||||
* authentication.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class PasswordCredentials implements CredentialsInterface
|
||||
{
|
||||
private ?string $password = null;
|
||||
private bool $resolved = false;
|
||||
|
||||
public function __construct(#[\SensitiveParameter] string $password)
|
||||
{
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
if (null === $this->password) {
|
||||
throw new LogicException('The credentials are erased as another listener already verified these credentials.');
|
||||
}
|
||||
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function markResolved(): void
|
||||
{
|
||||
$this->resolved = true;
|
||||
$this->password = null;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport;
|
||||
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CredentialsInterface;
|
||||
|
||||
/**
|
||||
* A Passport contains all security-related information that needs to be
|
||||
* validated during authentication.
|
||||
*
|
||||
* A passport badge can be used to add any additional information to the
|
||||
* passport.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
class Passport
|
||||
{
|
||||
protected UserInterface $user;
|
||||
|
||||
private array $badges = [];
|
||||
private array $attributes = [];
|
||||
|
||||
/**
|
||||
* @param CredentialsInterface $credentials The credentials to check for this authentication, use
|
||||
* SelfValidatingPassport if no credentials should be checked
|
||||
* @param BadgeInterface[] $badges
|
||||
*/
|
||||
public function __construct(UserBadge $userBadge, CredentialsInterface $credentials, array $badges = [])
|
||||
{
|
||||
$this->addBadge($userBadge);
|
||||
$this->addBadge($credentials);
|
||||
foreach ($badges as $badge) {
|
||||
$this->addBadge($badge);
|
||||
}
|
||||
}
|
||||
|
||||
public function getUser(): UserInterface
|
||||
{
|
||||
if (!isset($this->user)) {
|
||||
if (!$this->hasBadge(UserBadge::class)) {
|
||||
throw new \LogicException('Cannot get the Security user, no username or UserBadge configured for this passport.');
|
||||
}
|
||||
|
||||
$this->user = $this->getBadge(UserBadge::class)->getUser();
|
||||
}
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new security badge.
|
||||
*
|
||||
* A passport can hold only one instance of the same security badge.
|
||||
* This method replaces the current badge if it is already set on this
|
||||
* passport.
|
||||
*
|
||||
* @param string|null $badgeFqcn A FQCN to which the badge should be mapped to.
|
||||
* This allows replacing a built-in badge by a custom one using
|
||||
* e.g. addBadge(new MyCustomUserBadge(), UserBadge::class)
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addBadge(BadgeInterface $badge, ?string $badgeFqcn = null): static
|
||||
{
|
||||
$badgeFqcn ??= $badge::class;
|
||||
|
||||
$this->badges[$badgeFqcn] = $badge;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasBadge(string $badgeFqcn): bool
|
||||
{
|
||||
return isset($this->badges[$badgeFqcn]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TBadge of BadgeInterface
|
||||
*
|
||||
* @param class-string<TBadge> $badgeFqcn
|
||||
*
|
||||
* @return TBadge|null
|
||||
*/
|
||||
public function getBadge(string $badgeFqcn): ?BadgeInterface
|
||||
{
|
||||
return $this->badges[$badgeFqcn] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<BadgeInterface>, BadgeInterface>
|
||||
*/
|
||||
public function getBadges(): array
|
||||
{
|
||||
return $this->badges;
|
||||
}
|
||||
|
||||
public function setAttribute(string $name, mixed $value): void
|
||||
{
|
||||
$this->attributes[$name] = $value;
|
||||
}
|
||||
|
||||
public function getAttribute(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->attributes[$name] ?? $default;
|
||||
}
|
||||
|
||||
public function getAttributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Security\Http\Authenticator\Passport;
|
||||
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* An implementation used when there are no credentials to be checked (e.g.
|
||||
* API token authentication).
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
class SelfValidatingPassport extends Passport
|
||||
{
|
||||
/**
|
||||
* @param BadgeInterface[] $badges
|
||||
*/
|
||||
public function __construct(UserBadge $userBadge, array $badges = [])
|
||||
{
|
||||
$this->addBadge($userBadge);
|
||||
foreach ($badges as $badge) {
|
||||
$this->addBadge($badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user