diff --git a/src/Illuminate/Contracts/Validation/DataAwareRule.php b/src/Illuminate/Contracts/Validation/DataAwareRule.php new file mode 100644 index 000000000000..7ec7ab5a9fd9 --- /dev/null +++ b/src/Illuminate/Contracts/Validation/DataAwareRule.php @@ -0,0 +1,14 @@ +factory = $factory; + } + + /** + * Verify that the given data has not been compromised in public breaches. + * + * @param array $data + * @return bool + */ + public function verify($data) + { + $value = $data['value']; + $threshold = $data['threshold']; + + if (empty($value = (string) $value)) { + return false; + } + + [$hash, $hashPrefix] = $this->getHash($value); + + return ! $this->search($hashPrefix) + ->contains(function ($line) use ($hash, $hashPrefix, $threshold) { + [$hashSuffix, $count] = explode(':', $line); + + return $hashPrefix.$hashSuffix == $hash && $count > $threshold; + }); + } + + /** + * Get the hash and its first 5 chars. + * + * @param string $value + * @return array + */ + protected function getHash($value) + { + $hash = strtoupper(sha1((string) $value)); + + $hashPrefix = substr($hash, 0, 5); + + return [$hash, $hashPrefix]; + } + + /** + * Search by the given hash prefix and returns all occurrences of leaked passwords. + * + * @param string $hashPrefix + * @return \Illuminate\Support\Collection + */ + protected function search($hashPrefix) + { + try { + $response = $this->factory->withHeaders([ + 'Add-Padding' => true, + ])->get( + 'https://api.pwnedpasswords.com/range/'.$hashPrefix + ); + } catch (Exception $e) { + report($e); + } + + $body = (isset($response) && $response->successful()) + ? $response->body() + : ''; + + return Str::of($body)->trim()->explode("\n")->filter(function ($line) { + return Str::contains($line, ':'); + }); + } +} diff --git a/src/Illuminate/Validation/Rules/Password.php b/src/Illuminate/Validation/Rules/Password.php new file mode 100644 index 000000000000..eb3cdcb1c465 --- /dev/null +++ b/src/Illuminate/Validation/Rules/Password.php @@ -0,0 +1,252 @@ +min = max((int) $min, 1); + } + + /** + * Set the data under validation. + * + * @param array $data + * @return $this + */ + public function setData($data) + { + $this->data = $data; + + return $this; + } + + /** + * Sets the minimum size of the password. + * + * @param int $size + * @return $this + */ + public static function min($size) + { + return new static($size); + } + + /** + * Ensures the password has not been compromised in data leaks. + * + * @param int $threshold + * @return $this + */ + public function uncompromised($threshold = 0) + { + $this->uncompromised = true; + + $this->compromisedThreshold = $threshold; + + return $this; + } + + /** + * Makes the password require at least one uppercase and one lowercase letter. + * + * @return $this + */ + public function mixedCase() + { + $this->mixedCase = true; + + return $this; + } + + /** + * Makes the password require at least one letter. + * + * @return $this + */ + public function letters() + { + $this->letters = true; + + return $this; + } + + /** + * Makes the password require at least one number. + * + * @return $this + */ + public function numbers() + { + $this->numbers = true; + + return $this; + } + + /** + * Makes the password require at least one symbol. + * + * @return $this + */ + public function symbols() + { + $this->symbols = true; + + return $this; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * @return bool + */ + public function passes($attribute, $value) + { + $validator = Validator::make($this->data, [ + $attribute => 'string|min:'.$this->min, + ]); + + if ($validator->fails()) { + return $this->fail($validator->messages()->all()); + } + + $value = (string) $value; + + if ($this->mixedCase && ! preg_match('/(\p{Ll}+.*\p{Lu})|(\p{Lu}+.*\p{Ll})/u', $value)) { + $this->fail('The :attribute must contain at least one uppercase and one lowercase letter.'); + } + + if ($this->letters && ! preg_match('/\pL/u', $value)) { + $this->fail('The :attribute must contain at least one letter.'); + } + + if ($this->symbols && ! preg_match('/\p{Z}|\p{S}|\p{P}/u', $value)) { + $this->fail('The :attribute must contain at least one symbol.'); + } + + if ($this->numbers && ! preg_match('/\pN/u', $value)) { + $this->fail('The :attribute must contain at least one number.'); + } + + if (! empty($this->messages)) { + return false; + } + + if ($this->uncompromised && ! Container::getInstance()->make(UncompromisedVerifier::class)->verify([ + 'value' => $value, + 'threshold' => $this->compromisedThreshold, + ])) { + return $this->fail( + 'The given :attribute has appeared in a data leak. Please choose a different :attribute.' + ); + } + + return true; + } + + /** + * Get the validation error message. + * + * @return array + */ + public function message() + { + return $this->messages; + } + + /** + * Adds the given failures, and return false. + * + * @param array|string $messages + * @return bool + */ + protected function fail($messages) + { + $messages = collect(Arr::wrap($messages))->map(function ($message) { + return __($message); + })->all(); + + $this->messages = array_merge($this->messages, $messages); + + return false; + } +} diff --git a/src/Illuminate/Validation/ValidationServiceProvider.php b/src/Illuminate/Validation/ValidationServiceProvider.php index ce04447e58e0..936235f9e7bb 100755 --- a/src/Illuminate/Validation/ValidationServiceProvider.php +++ b/src/Illuminate/Validation/ValidationServiceProvider.php @@ -3,6 +3,8 @@ namespace Illuminate\Validation; use Illuminate\Contracts\Support\DeferrableProvider; +use Illuminate\Contracts\Validation\UncompromisedVerifier; +use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Support\ServiceProvider; class ValidationServiceProvider extends ServiceProvider implements DeferrableProvider @@ -15,7 +17,7 @@ class ValidationServiceProvider extends ServiceProvider implements DeferrablePro public function register() { $this->registerPresenceVerifier(); - + $this->registerUncompromisedVerifier(); $this->registerValidationFactory(); } @@ -52,6 +54,18 @@ protected function registerPresenceVerifier() }); } + /** + * Register the uncompromised password verifier. + * + * @return void + */ + protected function registerUncompromisedVerifier() + { + $this->app->singleton(UncompromisedVerifier::class, function ($app) { + return new NotPwnedVerifier($app[HttpFactory::class]); + }); + } + /** * Get the services provided by the provider. * diff --git a/src/Illuminate/Validation/Validator.php b/src/Illuminate/Validation/Validator.php index 70b8c17a6f3d..258ab19f540c 100755 --- a/src/Illuminate/Validation/Validator.php +++ b/src/Illuminate/Validation/Validator.php @@ -5,6 +5,7 @@ use BadMethodCallException; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Translation\Translator; +use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ImplicitRule; use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\Validator as ValidatorContract; @@ -748,6 +749,10 @@ protected function validateUsingCustomRule($attribute, $value, $rule) $value = is_array($value) ? $this->replacePlaceholders($value) : $value; + if ($rule instanceof DataAwareRule) { + $rule->setData($this->data); + } + if (! $rule->passes($attribute, $value)) { $this->failedRules[$attribute][get_class($rule)] = []; diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php new file mode 100644 index 000000000000..fb50b61a81fa --- /dev/null +++ b/tests/Validation/ValidationNotPwnedVerifierTest.php @@ -0,0 +1,126 @@ +assertFalse($verifier->verify([ + 'value' => $password, + 'threshold' => 0, + ])); + } + } + + public function testApiResponseGoesWrong() + { + $httpFactory = m::mock(HttpFactory::class); + $response = m::mock(Response::class); + + $httpFactory = m::mock(HttpFactory::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory->shouldReceive('get') + ->once() + ->andReturn($response); + + $response->shouldReceive('successful') + ->once() + ->andReturn(true); + + $response->shouldReceive('body') + ->once() + ->andReturn(''); + + $verifier = new NotPwnedVerifier($httpFactory); + + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + } + + public function testApiGoesDown() + { + $httpFactory = m::mock(HttpFactory::class); + $response = m::mock(Response::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory->shouldReceive('get') + ->once() + ->andReturn($response); + + $response->shouldReceive('successful') + ->once() + ->andReturn(false); + + $verifier = new NotPwnedVerifier($httpFactory); + + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + } + + public function testDnsDown() + { + $container = Container::getInstance(); + $exception = new ConnectionException(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + $exceptionHandler->shouldReceive('report')->once()->with($exception); + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $httpFactory = m::mock(HttpFactory::class); + + $httpFactory + ->shouldReceive('withHeaders') + ->once() + ->with(['Add-Padding' => true]) + ->andReturn($httpFactory); + + $httpFactory + ->shouldReceive('get') + ->once() + ->andThrow($exception); + + $verifier = new NotPwnedVerifier($httpFactory); + $this->assertTrue($verifier->verify([ + 'value' => 123123123, + 'threshold' => 0, + ])); + } +} diff --git a/tests/Validation/ValidationPasswordRuleTest.php b/tests/Validation/ValidationPasswordRuleTest.php new file mode 100644 index 000000000000..e397bb8d9033 --- /dev/null +++ b/tests/Validation/ValidationPasswordRuleTest.php @@ -0,0 +1,231 @@ +fails(Password::min(3), [['foo' => 'bar'], ['foo']], [ + 'validation.string', + 'validation.min.string', + ]); + + $this->fails(Password::min(3), [1234567, 545], [ + 'validation.string', + ]); + + $this->passes(Password::min(3), ['abcd', '454qb^', '接2133手田']); + } + + public function testMin() + { + $this->fails(new Password(8), ['a', 'ff', '12'], [ + 'validation.min.string', + ]); + + $this->fails(Password::min(3), ['a', 'ff', '12'], [ + 'validation.min.string', + ]); + + $this->passes(Password::min(3), ['333', 'abcd']); + $this->passes(new Password(8), ['88888888']); + } + + public function testMixedCase() + { + $this->fails(Password::min(2)->mixedCase(), ['nn', 'MM'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + ]); + + $this->passes(Password::min(2)->mixedCase(), ['Nn', 'Mn', 'âA']); + } + + public function testLetters() + { + $this->fails(Password::min(2)->letters(), ['11', '22', '^^', '``', '**'], [ + 'The my password must contain at least one letter.', + ]); + + $this->passes(Password::min(2)->letters(), ['1a', 'b2', 'â1', '1 京都府']); + } + + public function testNumbers() + { + $this->fails(Password::min(2)->numbers(), ['aa', 'bb', ' a', '京都府'], [ + 'The my password must contain at least one number.', + ]); + + $this->passes(Password::min(2)->numbers(), ['1a', 'b2', '00', '京都府 1']); + } + + public function testDefaultRules() + { + $this->fails(Password::min(3), [null], [ + 'validation.string', + 'validation.min.string', + ]); + } + + public function testSymbols() + { + $this->fails(Password::min(2)->symbols(), ['ab', '1v'], [ + 'The my password must contain at least one symbol.', + ]); + + $this->passes(Password::min(2)->symbols(), ['n^d', 'd^!', 'âè$', '金廿土弓竹中;']); + } + + public function testUncompromised() + { + $this->fails(Password::min(2)->uncompromised(), [ + '123456', + 'password', + 'welcome', + 'ninja', + 'abc123', + '123456789', + '12345678', + 'nuno', + ], [ + 'The given my password has appeared in a data leak. Please choose a different my password.', + ]); + + $this->passes(Password::min(2)->uncompromised(9999999), [ + 'nuno', + ]); + + $this->passes(Password::min(2)->uncompromised(), [ + '手田日尸Z難金木水口火女月土廿卜竹弓一十山', + '!p8VrB', + '&xe6VeKWF#n4', + '%HurHUnw7zM!', + 'rundeliekend', + '7Z^k5EvqQ9g%c!Jt9$ufnNpQy#Kf', + 'NRs*Gz2@hSmB$vVBSPDfqbRtEzk4nF7ZAbM29VMW$BPD%b2U%3VmJAcrY5eZGVxP%z%apnwSX', + ]); + } + + public function testMessagesOrder() + { + $makeRules = function () { + return ['required', Password::min(8)->mixedCase()->numbers()]; + }; + + $this->fails($makeRules(), [null], [ + 'validation.required', + ]); + + $this->fails($makeRules(), ['foo', 'azdazd', '1231231'], [ + 'validation.min.string', + ]); + + $this->fails($makeRules(), ['4564654564564'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + ]); + + $this->fails($makeRules(), ['aaaaaaaaa', 'TJQSJQSIUQHS'], [ + 'The my password must contain at least one uppercase and one lowercase letter.', + 'The my password must contain at least one number.', + ]); + + $this->passes($makeRules(), ['4564654564564Abc']); + + $makeRules = function () { + return ['nullable', 'confirmed', Password::min(8)->letters()->symbols()->uncompromised()]; + }; + + $this->passes($makeRules(), [null]); + + $this->fails($makeRules(), ['foo', 'azdazd', '1231231'], [ + 'validation.min.string', + ]); + + $this->fails($makeRules(), ['aaaaaaaaa', 'TJQSJQSIUQHS'], [ + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['4564654564564'], [ + 'The my password must contain at least one letter.', + 'The my password must contain at least one symbol.', + ]); + + $this->fails($makeRules(), ['abcabcabc!'], [ + 'The given my password has appeared in a data leak. Please choose a different my password.', + ]); + + $v = new Validator( + resolve('translator'), + ['my_password' => 'Nuno'], + ['my_password' => ['nullable', 'confirmed', Password::min(3)->letters()]] + ); + + $this->assertFalse($v->passes()); + + $this->assertSame( + ['my_password' => ['validation.confirmed']], + $v->messages()->toArray() + ); + } + + protected function passes($rule, $values) + { + $this->testRule($rule, $values, true, []); + } + + protected function fails($rule, $values, $messages) + { + $this->testRule($rule, $values, false, $messages); + } + + protected function testRule($rule, $values, $result, $messages) + { + foreach ($values as $value) { + $v = new Validator( + resolve('translator'), + ['my_password' => $value, 'my_password_confirmation' => $value], + ['my_password' => is_object($rule) ? clone $rule : $rule] + ); + + $this->assertSame($result, $v->passes()); + + $this->assertSame( + $result ? [] : ['my_password' => $messages], + $v->messages()->toArray() + ); + } + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + } +} diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 7d592324e47c..661d913d8d60 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Translation\Translator as TranslatorContract; +use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ImplicitRule; use Illuminate\Contracts\Validation\Rule; use Illuminate\Database\Eloquent\Model; @@ -5244,6 +5245,66 @@ public function message() $this->assertSame('name must be taylor', $v->errors()->get('name')[0]); $this->assertSame('name must be a first name', $v->errors()->get('name')[1]); $this->assertSame('validation.string', $v->errors()->get('name')[2]); + + // Test access to the validator data + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['password' => 'foo', 'password_confirmation' => 'foo'], + [ + 'password' => [ + new class implements Rule, DataAwareRule { + protected $data; + + public function setData($data) + { + $this->data = $data; + } + + public function passes($attribute, $value) + { + return $value === $this->data['password_confirmation']; + } + + public function message() + { + return ['The :attribute confirmation does not match.']; + } + }, 'string', + ], + ] + ); + + $this->assertTrue($v->passes()); + + $v = new Validator( + $this->getIlluminateArrayTranslator(), + ['password' => 'foo', 'password_confirmation' => 'bar'], + [ + 'password' => [ + new class implements Rule, DataAwareRule { + protected $data; + + public function setData($data) + { + $this->data = $data; + } + + public function passes($attribute, $value) + { + return $value === $this->data['password_confirmation']; + } + + public function message() + { + return ['The :attribute confirmation does not match.']; + } + }, 'string', + ], + ] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('The password confirmation does not match.', $v->errors()->get('password')[0]); } public function testCustomValidationObjectWithDotKeysIsCorrectlyPassedValue()