NotCompromisedPasswordValidator.php 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Validator\Constraints;
  11. use Symfony\Component\HttpClient\HttpClient;
  12. use Symfony\Component\Validator\Constraint;
  13. use Symfony\Component\Validator\ConstraintValidator;
  14. use Symfony\Component\Validator\Exception\UnexpectedTypeException;
  15. use Symfony\Component\Validator\Exception\UnexpectedValueException;
  16. use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
  17. use Symfony\Contracts\HttpClient\HttpClientInterface;
  18. /**
  19. * Checks if a password has been leaked in a data breach using haveibeenpwned.com's API.
  20. * Use a k-anonymity model to protect the password being searched for.
  21. *
  22. * @see https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange
  23. *
  24. * @author Kévin Dunglas <dunglas@gmail.com>
  25. */
  26. class NotCompromisedPasswordValidator extends ConstraintValidator
  27. {
  28. private const DEFAULT_API_ENDPOINT = 'https://api.pwnedpasswords.com/range/%s';
  29. private $httpClient;
  30. private $charset;
  31. private $enabled;
  32. private $endpoint;
  33. public function __construct(HttpClientInterface $httpClient = null, string $charset = 'UTF-8', bool $enabled = true, string $endpoint = null)
  34. {
  35. if (null === $httpClient && !class_exists(HttpClient::class)) {
  36. throw new \LogicException(sprintf('The "%s" class requires the "HttpClient" component. Try running "composer require symfony/http-client".', self::class));
  37. }
  38. $this->httpClient = $httpClient ?? HttpClient::create();
  39. $this->charset = $charset;
  40. $this->enabled = $enabled;
  41. $this->endpoint = $endpoint ?? self::DEFAULT_API_ENDPOINT;
  42. }
  43. /**
  44. * {@inheritdoc}
  45. *
  46. * @throws ExceptionInterface
  47. */
  48. public function validate($value, Constraint $constraint)
  49. {
  50. if (!$constraint instanceof NotCompromisedPassword) {
  51. throw new UnexpectedTypeException($constraint, NotCompromisedPassword::class);
  52. }
  53. if (!$this->enabled) {
  54. return;
  55. }
  56. if (null !== $value && !is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
  57. throw new UnexpectedValueException($value, 'string');
  58. }
  59. $value = (string) $value;
  60. if ('' === $value) {
  61. return;
  62. }
  63. if ('UTF-8' !== $this->charset) {
  64. $value = mb_convert_encoding($value, 'UTF-8', $this->charset);
  65. }
  66. $hash = strtoupper(sha1($value));
  67. $hashPrefix = substr($hash, 0, 5);
  68. $url = sprintf($this->endpoint, $hashPrefix);
  69. try {
  70. $result = $this->httpClient->request('GET', $url)->getContent();
  71. } catch (ExceptionInterface $e) {
  72. if ($constraint->skipOnError) {
  73. return;
  74. }
  75. throw $e;
  76. }
  77. foreach (explode("\r\n", $result) as $line) {
  78. [$hashSuffix, $count] = explode(':', $line);
  79. if ($hashPrefix.$hashSuffix === $hash && $constraint->threshold <= (int) $count) {
  80. $this->context->buildViolation($constraint->message)
  81. ->setCode(NotCompromisedPassword::COMPROMISED_PASSWORD_ERROR)
  82. ->addViolation();
  83. return;
  84. }
  85. }
  86. }
  87. }