From fd58870ac03aa2f0ca258164a876c43af4a43f61 Mon Sep 17 00:00:00 2001 From: Bernhard Schussek Date: Thu, 10 Apr 2014 18:24:43 +0200 Subject: [PATCH] [Validator] Simplified IBAN validation algorithm --- .../Validator/Constraints/IbanValidator.php | 101 ++++++++++++++---- .../Tests/Constraints/IbanValidatorTest.php | 2 + 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/IbanValidator.php b/src/Symfony/Component/Validator/Constraints/IbanValidator.php index 640da3c702..615518883b 100644 --- a/src/Symfony/Component/Validator/Constraints/IbanValidator.php +++ b/src/Symfony/Component/Validator/Constraints/IbanValidator.php @@ -13,10 +13,12 @@ namespace Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; /** * @author Manuel Reinhard * @author Michael Schummel + * @author Bernhard Schussek * @link http://www.michael-schummel.de/2007/10/05/iban-prufung-mit-php/ */ class IbanValidator extends ConstraintValidator @@ -30,41 +32,98 @@ class IbanValidator extends ConstraintValidator return; } - // An IBAN without a country code is not an IBAN. - if (0 === preg_match('/[A-Z]/', $value)) { - $this->context->addViolation($constraint->message, array('{{ value }}' => $value)); + if (!is_scalar($value) && !(is_object($value) && method_exists($value, '__toString'))) { + throw new UnexpectedTypeException($value, 'string'); + } + + // Remove spaces + $canonicalized = str_replace(' ', '', $value); + + if (strlen($canonicalized) < 4) { + $this->context->addViolation($constraint->message, array( + '{{ value }}' => $value, + )); return; } - $teststring = preg_replace('/\s+/', '', $value); - - if (strlen($teststring) < 4) { - $this->context->addViolation($constraint->message, array('{{ value }}' => $value)); + // The IBAN must have at least 4 characters, start with a country + // code and contain only digits and (uppercase) characters + if (strlen($canonicalized) < 4 || !ctype_upper($canonicalized{0}) + || !ctype_upper($canonicalized{1}) || !ctype_alnum($canonicalized)) { + $this->context->addViolation($constraint->message, array( + '{{ value }}' => $value, + )); return; } - $teststring = substr($teststring, 4) - .strval(ord($teststring{0}) - 55) - .strval(ord($teststring{1}) - 55) - .substr($teststring, 2, 2); + // Move the first four characters to the end + // e.g. CH93 0076 2011 6238 5295 7 + // -> 0076 2011 6238 5295 7 CH93 + $canonicalized = substr($canonicalized, 4).substr($canonicalized, 0, 4); - $teststring = preg_replace_callback('/[A-Z]/', function ($letter) { - return intval(ord(strtolower($letter[0])) - 87); - }, $teststring); + // Convert all remaining letters to their ordinals + // The result is an integer, which is too large for PHP's int + // data type, so we store it in a string instead. + // e.g. 0076 2011 6238 5295 7 CH93 + // -> 0076 2011 6238 5295 7 121893 + $checkSum = $this->toBigInt($canonicalized); - $rest = 0; - $strlen = strlen($teststring); - for ($pos = 0; $pos < $strlen; $pos += 7) { - $part = strval($rest).substr($teststring, $pos, 7); - $rest = intval($part) % 97; + if (false === $checkSum) { + $this->context->addViolation($constraint->message, array( + '{{ value }}' => $value, + )); + + return; } - if ($rest != 1) { - $this->context->addViolation($constraint->message, array('{{ value }}' => $value)); + // Do a modulo-97 operation on the large integer + // We cannot use PHP's modulo operator, so we calculate the + // modulo step-wisely instead + if (1 !== $this->bigModulo97($checkSum)) { + $this->context->addViolation($constraint->message, array( + '{{ value }}' => $value, + )); return; } } + + private function toBigInt($string) + { + $chars = str_split($string); + $bigInt = ''; + + foreach ($chars as $char) { + // Convert uppercase characters to ordinals, starting with 10 for "A" + if (ctype_upper($char)) { + $bigInt .= (ord($char) - 55); + + continue; + } + + // Disallow lowercase characters + if (ctype_lower($char)) { + return false; + } + + // Simply append digits + $bigInt .= $char; + } + + return $bigInt; + } + + private function bigModulo97($bigInt) + { + $parts = str_split($bigInt, 7); + $rest = 0; + + foreach ($parts as $part) { + $rest = ($rest.$part) % 97; + } + + return $rest; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php index 60ba94c32c..cf0802fed2 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/IbanValidatorTest.php @@ -54,6 +54,7 @@ class IbanValidatorTest extends \PHPUnit_Framework_TestCase { return array( array('CH9300762011623852957'), // Switzerland without spaces + array('CH93 0076 2011 6238 5295 7'), // Switzerland with multiple spaces //Country list //http://www.rbs.co.uk/corporate/international/g0/guide-to-international-business/regulatory-information/iban/iban-example.ashx @@ -182,6 +183,7 @@ class IbanValidatorTest extends \PHPUnit_Framework_TestCase array('foo'), array('123'), array('0750447346'), + array('CH930076201162385295]'), //Ibans with lower case values are invalid array('Ae260211000000230064016'),