Skip to content

Commit

Permalink
[8.x] Adds password rule (#36960)
Browse files Browse the repository at this point in the history
* Adds password rule

* Typo

* Fixes default compromised number

* Adds "Add-Padding" header to not pwned verifier

* Improves testing

* work on rule

* Adds uncompromised threshold

* Updates docs

* Removes non used import

* Updates property name

* Fixes docs

* Updates test methods

* Adds more tests

* Removes mixed case test

* Adds more tests

* Adds tests

* Update NotPwnedVerifier.php

Co-authored-by: Taylor Otwell <taylorotwell@gmail.com>
Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
3 people authored Apr 23, 2021
1 parent e3fcd97 commit 5a65f25
Show file tree
Hide file tree
Showing 9 changed files with 813 additions and 1 deletion.
14 changes: 14 additions & 0 deletions src/Illuminate/Contracts/Validation/DataAwareRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Illuminate\Contracts\Validation;

interface DataAwareRule
{
/**
* Set the data under validation.
*
* @param array $data
* @return $this
*/
public function setData($data);
}
14 changes: 14 additions & 0 deletions src/Illuminate/Contracts/Validation/UncompromisedVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Illuminate\Contracts\Validation;

interface UncompromisedVerifier
{
/**
* Verify that the given data has not been compromised in data leaks.
*
* @param array $data
* @return bool
*/
public function verify($data);
}
95 changes: 95 additions & 0 deletions src/Illuminate/Validation/NotPwnedVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace Illuminate\Validation;

use Exception;
use Illuminate\Contracts\Validation\UncompromisedVerifier;
use Illuminate\Support\Str;

class NotPwnedVerifier implements UncompromisedVerifier
{
/**
* The HTTP factory instance.
*
* @var \Illuminate\Http\Client\Factory
*/
protected $factory;

/**
* Create a new uncompromised verifier.
*
* @param \Illuminate\Http\Client\Factory $factory
* @return void
*/
public function __construct($factory)
{
$this->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, ':');
});
}
}
252 changes: 252 additions & 0 deletions src/Illuminate/Validation/Rules/Password.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<?php

namespace Illuminate\Validation\Rules;

use Illuminate\Container\Container;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\UncompromisedVerifier;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;

class Password implements Rule, DataAwareRule
{
/**
* The data under validation.
*
* @var array
*/
protected $data;

/**
* The minimum size of the password.
*
* @var int
*/
protected $min = 8;

/**
* If the password requires at least one uppercase and one lowercase letter.
*
* @var bool
*/
protected $mixedCase = false;

/**
* If the password requires at least one letter.
*
* @var bool
*/
protected $letters = false;

/**
* If the password requires at least one number.
*
* @var bool
*/
protected $numbers = false;

/**
* If the password requires at least one symbol.
*
* @var bool
*/
protected $symbols = false;

/**
* If the password should has not been compromised in data leaks.
*
* @var bool
*/
protected $uncompromised = false;

/**
* The number of times a password can appear in data leaks before being consider compromised.
*
* @var int
*/
protected $compromisedThreshold = 0;

/**
* The failure messages, if any.
*
* @var array
*/
protected $messages = [];

/**
* Create a new rule instance.
*
* @param int $min
* @return void
*/
public function __construct($min)
{
$this->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;
}
}
Loading

0 comments on commit 5a65f25

Please sign in to comment.