The validation of verification codes is handled by the VerificationCodeValidatorInterface and its default implementation, VerificationCodeValidator. This process ensures strict adherence to lifecycle rules, constant-time comparison, and robust anti-brute force mechanisms.
When validate() or validateByCode() is called:
public function validate(IdentityTypeEnum $identityType, string $identityId, VerificationPurposeEnum $purpose, string $plainCode, ?string $usedIp = null): VerificationResultThe service executes the following sequence:
-
Lookup Active Code:
- The validator queries the
VerificationCodeRepositoryInterface->findActive(...)using the exact$identityType,$identityId, and$purpose. - If no matching active code is found, it immediately returns a
VerificationResult::failure('Invalid code.'). This prevents an attacker from determining if a code existed previously.
- The validator queries the
-
Evaluate Expiry (TTL):
- It checks the
expiresAtproperty of theVerificationCodeDTO against the current time (ClockInterface->now()). - If the code is expired, the validator calls
$this->repository->expire($code->id)to ensure the state is consistent in the database and returns aVerificationResult::failure('Invalid code.').
- It checks the
-
Evaluate Attempts (Anti-Brute Force):
- It checks the
attemptscount against themaxAttemptsallowed by the policy. - If
attemptsis greater than or equal tomaxAttempts, the code is locked out permanently, even if the user correctly guessed it this time. - The validator calls
$this->repository->expire($code->id)to explicitly mark itEXPIREDand returnsVerificationResult::failure('Invalid code.').
- It checks the
-
Constant-Time Comparison:
- The incoming
$plainCodeis hashed:$inputHash = hash('sha256', $plainCode). - The validator uses PHP's
hash_equals($code->codeHash, $inputHash)to compare the input against the stored hash in constant time, mitigating timing attacks. - If the hashes do not match:
- It immediately increments the failed attempts counter:
$this->repository->incrementAttempts($code->id). - It then re-evaluates the attempts. If the newly incremented total reaches the
maxAttempts, the code is expired:$this->repository->expire($code->id). - It returns a
VerificationResult::failure('Invalid code.').
- It immediately increments the failed attempts counter:
- The incoming
-
Usage Marking:
- If the hashes match and all checks pass, the code is valid.
- The validator explicitly marks the code as used and records the IP address (if provided) for auditing:
$this->repository->markUsed($code->id, $usedIp). - It returns a
VerificationResult::success(...)containing the matched identity and purpose.
The validateByCode() method allows checking a code without knowing the identity beforehand. It follows a similar, but reversed, logic:
- It hashes the input
$plainCode. - It queries the repository using
$this->repository->findByCodeHash($codeHash). - If found, it then performs the exact same status (
VerificationCodeStatus::ACTIVE), expiry (expiresAt), and attempts (maxAttempts) checks as the standard validation. - If successful, it marks the code as used and returns the embedded identity details.
- Generic Failure Messages: The generic
'Invalid code.'message prevents leaking whether the failure was due to a non-existent code, an expired code, an incorrect guess, or a brute-force lockout. - Constant-Time Validation: Using
hash_equals()is crucial to prevent attackers from inferring the correct hash by measuring response times. - Strict Brute-Force Limits: Incrementing the attempt counter and expiring the code upon reaching the limit is hard-coded into the domain service. It cannot be bypassed, ensuring robust protection against automated guessing.