Chris@0: Chris@0: * Chris@0: * For the full copyright and license information, please view the LICENSE Chris@0: * file that was distributed with this source code. Chris@0: */ Chris@0: Chris@0: namespace Symfony\Component\Validator\Constraints; Chris@0: Chris@0: use Symfony\Component\Validator\Constraint; Chris@0: use Symfony\Component\Validator\ConstraintValidator; Chris@0: use Symfony\Component\Validator\Exception\UnexpectedTypeException; Chris@0: Chris@0: /** Chris@0: * @author Manuel Reinhard Chris@0: * @author Michael Schummel Chris@0: * @author Bernhard Schussek Chris@0: * Chris@0: * @see http://www.michael-schummel.de/2007/10/05/iban-prufung-mit-php/ Chris@0: */ Chris@0: class IbanValidator extends ConstraintValidator Chris@0: { Chris@0: /** Chris@0: * IBAN country specific formats. Chris@0: * Chris@0: * The first 2 characters from an IBAN format are the two-character ISO country code. Chris@0: * The following 2 characters represent the check digits calculated from the rest of the IBAN characters. Chris@0: * The rest are up to thirty alphanumeric characters for Chris@0: * a BBAN (Basic Bank Account Number) which has a fixed length per country and, Chris@0: * included within it, a bank identifier with a fixed position and a fixed length per country Chris@0: * Chris@12: * @see https://www.swift.com/sites/default/files/resources/iban_registry.pdf Chris@0: */ Chris@17: private static $formats = [ Chris@0: 'AD' => 'AD\d{2}\d{4}\d{4}[\dA-Z]{12}', // Andorra Chris@0: 'AE' => 'AE\d{2}\d{3}\d{16}', // United Arab Emirates Chris@0: 'AL' => 'AL\d{2}\d{8}[\dA-Z]{16}', // Albania Chris@0: 'AO' => 'AO\d{2}\d{21}', // Angola Chris@0: 'AT' => 'AT\d{2}\d{5}\d{11}', // Austria Chris@0: 'AX' => 'FI\d{2}\d{6}\d{7}\d{1}', // Aland Islands Chris@0: 'AZ' => 'AZ\d{2}[A-Z]{4}[\dA-Z]{20}', // Azerbaijan Chris@0: 'BA' => 'BA\d{2}\d{3}\d{3}\d{8}\d{2}', // Bosnia and Herzegovina Chris@0: 'BE' => 'BE\d{2}\d{3}\d{7}\d{2}', // Belgium Chris@0: 'BF' => 'BF\d{2}\d{23}', // Burkina Faso Chris@0: 'BG' => 'BG\d{2}[A-Z]{4}\d{4}\d{2}[\dA-Z]{8}', // Bulgaria Chris@0: 'BH' => 'BH\d{2}[A-Z]{4}[\dA-Z]{14}', // Bahrain Chris@0: 'BI' => 'BI\d{2}\d{12}', // Burundi Chris@0: 'BJ' => 'BJ\d{2}[A-Z]{1}\d{23}', // Benin Chris@14: 'BY' => 'BY\d{2}[\dA-Z]{4}\d{4}[\dA-Z]{16}', // Belarus - https://bank.codes/iban/structure/belarus/ Chris@0: 'BL' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Barthelemy Chris@0: 'BR' => 'BR\d{2}\d{8}\d{5}\d{10}[A-Z][\dA-Z]', // Brazil Chris@0: 'CG' => 'CG\d{2}\d{23}', // Congo Chris@0: 'CH' => 'CH\d{2}\d{5}[\dA-Z]{12}', // Switzerland Chris@0: 'CI' => 'CI\d{2}[A-Z]{1}\d{23}', // Ivory Coast Chris@0: 'CM' => 'CM\d{2}\d{23}', // Cameron Chris@14: 'CR' => 'CR\d{2}0\d{3}\d{14}', // Costa Rica Chris@0: 'CV' => 'CV\d{2}\d{21}', // Cape Verde Chris@0: 'CY' => 'CY\d{2}\d{3}\d{5}[\dA-Z]{16}', // Cyprus Chris@0: 'CZ' => 'CZ\d{2}\d{20}', // Czech Republic Chris@0: 'DE' => 'DE\d{2}\d{8}\d{10}', // Germany Chris@0: 'DO' => 'DO\d{2}[\dA-Z]{4}\d{20}', // Dominican Republic Chris@0: 'DK' => 'DK\d{2}\d{4}\d{10}', // Denmark Chris@0: 'DZ' => 'DZ\d{2}\d{20}', // Algeria Chris@0: 'EE' => 'EE\d{2}\d{2}\d{2}\d{11}\d{1}', // Estonia Chris@0: 'ES' => 'ES\d{2}\d{4}\d{4}\d{1}\d{1}\d{10}', // Spain (also includes Canary Islands, Ceuta and Melilla) Chris@0: 'FI' => 'FI\d{2}\d{6}\d{7}\d{1}', // Finland Chris@0: 'FO' => 'FO\d{2}\d{4}\d{9}\d{1}', // Faroe Islands Chris@0: 'FR' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France Chris@0: 'GF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Guyana Chris@0: 'GB' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom of Great Britain and Northern Ireland Chris@0: 'GE' => 'GE\d{2}[A-Z]{2}\d{16}', // Georgia Chris@0: 'GI' => 'GI\d{2}[A-Z]{4}[\dA-Z]{15}', // Gibraltar Chris@0: 'GL' => 'GL\d{2}\d{4}\d{9}\d{1}', // Greenland Chris@0: 'GP' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Guadeloupe Chris@0: 'GR' => 'GR\d{2}\d{3}\d{4}[\dA-Z]{16}', // Greece Chris@0: 'GT' => 'GT\d{2}[\dA-Z]{4}[\dA-Z]{20}', // Guatemala Chris@0: 'HR' => 'HR\d{2}\d{7}\d{10}', // Croatia Chris@0: 'HU' => 'HU\d{2}\d{3}\d{4}\d{1}\d{15}\d{1}', // Hungary Chris@0: 'IE' => 'IE\d{2}[A-Z]{4}\d{6}\d{8}', // Ireland Chris@0: 'IL' => 'IL\d{2}\d{3}\d{3}\d{13}', // Israel Chris@0: 'IR' => 'IR\d{2}\d{22}', // Iran Chris@0: 'IS' => 'IS\d{2}\d{4}\d{2}\d{6}\d{10}', // Iceland Chris@0: 'IT' => 'IT\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}', // Italy Chris@0: 'JO' => 'JO\d{2}[A-Z]{4}\d{4}[\dA-Z]{18}', // Jordan Chris@0: 'KW' => 'KW\d{2}[A-Z]{4}\d{22}', // KUWAIT Chris@0: 'KZ' => 'KZ\d{2}\d{3}[\dA-Z]{13}', // Kazakhstan Chris@0: 'LB' => 'LB\d{2}\d{4}[\dA-Z]{20}', // LEBANON Chris@0: 'LI' => 'LI\d{2}\d{5}[\dA-Z]{12}', // Liechtenstein (Principality of) Chris@0: 'LT' => 'LT\d{2}\d{5}\d{11}', // Lithuania Chris@0: 'LU' => 'LU\d{2}\d{3}[\dA-Z]{13}', // Luxembourg Chris@0: 'LV' => 'LV\d{2}[A-Z]{4}[\dA-Z]{13}', // Latvia Chris@0: 'MC' => 'MC\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Monaco Chris@0: 'MD' => 'MD\d{2}[\dA-Z]{2}[\dA-Z]{18}', // Moldova Chris@0: 'ME' => 'ME\d{2}\d{3}\d{13}\d{2}', // Montenegro Chris@0: 'MF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Martin (French part) Chris@0: 'MG' => 'MG\d{2}\d{23}', // Madagascar Chris@0: 'MK' => 'MK\d{2}\d{3}[\dA-Z]{10}\d{2}', // Macedonia, Former Yugoslav Republic of Chris@0: 'ML' => 'ML\d{2}[A-Z]{1}\d{23}', // Mali Chris@0: 'MQ' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Martinique Chris@0: 'MR' => 'MR13\d{5}\d{5}\d{11}\d{2}', // Mauritania Chris@0: 'MT' => 'MT\d{2}[A-Z]{4}\d{5}[\dA-Z]{18}', // Malta Chris@0: 'MU' => 'MU\d{2}[A-Z]{4}\d{2}\d{2}\d{12}\d{3}[A-Z]{3}', // Mauritius Chris@0: 'MZ' => 'MZ\d{2}\d{21}', // Mozambique Chris@0: 'NC' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // New Caledonia Chris@0: 'NL' => 'NL\d{2}[A-Z]{4}\d{10}', // The Netherlands Chris@0: 'NO' => 'NO\d{2}\d{4}\d{6}\d{1}', // Norway Chris@0: 'PF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Polynesia Chris@0: 'PK' => 'PK\d{2}[A-Z]{4}[\dA-Z]{16}', // Pakistan Chris@0: 'PL' => 'PL\d{2}\d{8}\d{16}', // Poland Chris@0: 'PM' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Pierre et Miquelon Chris@0: 'PS' => 'PS\d{2}[A-Z]{4}[\dA-Z]{21}', // Palestine, State of Chris@0: 'PT' => 'PT\d{2}\d{4}\d{4}\d{11}\d{2}', // Portugal (plus Azores and Madeira) Chris@0: 'QA' => 'QA\d{2}[A-Z]{4}[\dA-Z]{21}', // Qatar Chris@0: 'RE' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Reunion Chris@0: 'RO' => 'RO\d{2}[A-Z]{4}[\dA-Z]{16}', // Romania Chris@0: 'RS' => 'RS\d{2}\d{3}\d{13}\d{2}', // Serbia Chris@0: 'SA' => 'SA\d{2}\d{2}[\dA-Z]{18}', // Saudi Arabia Chris@0: 'SE' => 'SE\d{2}\d{3}\d{16}\d{1}', // Sweden Chris@0: 'SI' => 'SI\d{2}\d{5}\d{8}\d{2}', // Slovenia Chris@0: 'SK' => 'SK\d{2}\d{4}\d{6}\d{10}', // Slovak Republic Chris@0: 'SM' => 'SM\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}', // San Marino Chris@0: 'SN' => 'SN\d{2}[A-Z]{1}\d{23}', // Senegal Chris@0: 'TF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Southern Territories Chris@0: 'TL' => 'TL\d{2}\d{3}\d{14}\d{2}', // Timor-Leste Chris@0: 'TN' => 'TN59\d{2}\d{3}\d{13}\d{2}', // Tunisia Chris@0: 'TR' => 'TR\d{2}\d{5}[\dA-Z]{1}[\dA-Z]{16}', // Turkey Chris@12: 'UA' => 'UA\d{2}\d{6}[\dA-Z]{19}', // Ukraine Chris@17: 'VA' => 'VA\d{2}\d{3}\d{15}', // Vatican City State Chris@0: 'VG' => 'VG\d{2}[A-Z]{4}\d{16}', // Virgin Islands, British Chris@0: 'WF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Wallis and Futuna Islands Chris@0: 'XK' => 'XK\d{2}\d{4}\d{10}\d{2}', // Republic of Kosovo Chris@0: 'YT' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Mayotte Chris@17: ]; Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function validate($value, Constraint $constraint) Chris@0: { Chris@0: if (!$constraint instanceof Iban) { Chris@0: throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\Iban'); Chris@0: } Chris@0: Chris@0: if (null === $value || '' === $value) { Chris@0: return; Chris@0: } Chris@0: Chris@17: if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) { Chris@0: throw new UnexpectedTypeException($value, 'string'); Chris@0: } Chris@0: Chris@0: $value = (string) $value; Chris@0: Chris@0: // Remove spaces and convert to uppercase Chris@0: $canonicalized = str_replace(' ', '', strtoupper($value)); Chris@0: Chris@0: // The IBAN must contain only digits and characters... Chris@0: if (!ctype_alnum($canonicalized)) { Chris@0: $this->context->buildViolation($constraint->message) Chris@0: ->setParameter('{{ value }}', $this->formatValue($value)) Chris@0: ->setCode(Iban::INVALID_CHARACTERS_ERROR) Chris@0: ->addViolation(); Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: // ...start with a two-letter country code Chris@0: $countryCode = substr($canonicalized, 0, 2); Chris@0: Chris@0: if (!ctype_alpha($countryCode)) { Chris@0: $this->context->buildViolation($constraint->message) Chris@0: ->setParameter('{{ value }}', $this->formatValue($value)) Chris@0: ->setCode(Iban::INVALID_COUNTRY_CODE_ERROR) Chris@0: ->addViolation(); Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: // ...have a format available Chris@18: if (!\array_key_exists($countryCode, self::$formats)) { Chris@0: $this->context->buildViolation($constraint->message) Chris@0: ->setParameter('{{ value }}', $this->formatValue($value)) Chris@0: ->setCode(Iban::NOT_SUPPORTED_COUNTRY_CODE_ERROR) Chris@0: ->addViolation(); Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: // ...and have a valid format Chris@0: if (!preg_match('/^'.self::$formats[$countryCode].'$/', $canonicalized) Chris@0: ) { Chris@0: $this->context->buildViolation($constraint->message) Chris@0: ->setParameter('{{ value }}', $this->formatValue($value)) Chris@0: ->setCode(Iban::INVALID_FORMAT_ERROR) Chris@0: ->addViolation(); Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: // Move the first four characters to the end Chris@0: // e.g. CH93 0076 2011 6238 5295 7 Chris@0: // -> 0076 2011 6238 5295 7 CH93 Chris@0: $canonicalized = substr($canonicalized, 4).substr($canonicalized, 0, 4); Chris@0: Chris@0: // Convert all remaining letters to their ordinals Chris@0: // The result is an integer, which is too large for PHP's int Chris@0: // data type, so we store it in a string instead. Chris@0: // e.g. 0076 2011 6238 5295 7 CH93 Chris@0: // -> 0076 2011 6238 5295 7 121893 Chris@0: $checkSum = self::toBigInt($canonicalized); Chris@0: Chris@0: // Do a modulo-97 operation on the large integer Chris@0: // We cannot use PHP's modulo operator, so we calculate the Chris@0: // modulo step-wisely instead Chris@0: if (1 !== self::bigModulo97($checkSum)) { Chris@0: $this->context->buildViolation($constraint->message) Chris@0: ->setParameter('{{ value }}', $this->formatValue($value)) Chris@0: ->setCode(Iban::CHECKSUM_FAILED_ERROR) Chris@0: ->addViolation(); Chris@0: } Chris@0: } Chris@0: Chris@0: private static function toBigInt($string) Chris@0: { Chris@0: $chars = str_split($string); Chris@0: $bigInt = ''; Chris@0: Chris@0: foreach ($chars as $char) { Chris@0: // Convert uppercase characters to ordinals, starting with 10 for "A" Chris@0: if (ctype_upper($char)) { Chris@17: $bigInt .= (\ord($char) - 55); Chris@0: Chris@0: continue; Chris@0: } Chris@0: Chris@0: // Simply append digits Chris@0: $bigInt .= $char; Chris@0: } Chris@0: Chris@0: return $bigInt; Chris@0: } Chris@0: Chris@0: private static function bigModulo97($bigInt) Chris@0: { Chris@0: $parts = str_split($bigInt, 7); Chris@0: $rest = 0; Chris@0: Chris@0: foreach ($parts as $part) { Chris@0: $rest = ($rest.$part) % 97; Chris@0: } Chris@0: Chris@0: return $rest; Chris@0: } Chris@0: }