diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index c7b45b63..d7789dc3 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -60,15 +60,111 @@ class VerificationCode extends Model */ protected $hidden = []; - /** on boot generate code */ + /** + * Outcomes of {@see check()}. + */ + public const CHECK_VALID = 'valid'; + public const CHECK_INVALID = 'invalid'; + public const CHECK_EXPIRED = 'expired'; + public const CHECK_LOCKED = 'locked'; + + /** + * The plain code of a code made by {@see issue()}. It lives on this instance only, so the + * caller can send it once; the database keeps an HMAC of it. + */ + public ?string $plainCode = null; + + /** on boot generate code, unless one was set already (a hashed code from {@see issue()}) */ public static function boot() { parent::boot(); static::creating(function ($model) { - $model->code = random_int(100000, 999999); + if (blank($model->code)) { + $model->code = random_int(100000, 999999); + } }); } + /** + * Issue a code that is stored hashed, for flows where a leaked table must not give away + * live codes. The plain code is on the returned instance's `plainCode`; sending it is up + * to the caller. + * + * Options: `expireAfter` (default 10 minutes from now), `meta` (merged into the code's meta) + * and `status` (default 'active'). + * + * @param mixed $subject the model the code is for, or null + */ + public static function issue($subject, string $for, array $options = []): static + { + $plainCode = (string) random_int(100000, 999999); + + $verifyCode = new static(); + $verifyCode->for = $for; + $verifyCode->status = data_get($options, 'status', 'active'); + $verifyCode->expires_at = data_get($options, 'expireAfter', Carbon::now()->addMinutes(10)); + $verifyCode->code = static::hashCode($plainCode); + $verifyCode->meta = array_merge((array) data_get($options, 'meta', []), ['hashed' => true, 'attempts' => 0]); + + if ($subject) { + $verifyCode->setSubject($subject, false); + } + + $verifyCode->save(); + $verifyCode->plainCode = $plainCode; + + return $verifyCode; + } + + /** + * The HMAC a hashed code is stored as, keyed by the app key. + */ + public static function hashCode(string $plainCode): string + { + return hash_hmac('sha256', $plainCode, (string) config('app.key', '')); + } + + /** + * Check a plain code against this one. A wrong code counts an attempt, and the code locks + * itself on the last allowed attempt, so it can't be guessed further. + * + * Read the code without the expiry scope to tell an expired code apart: the scope hides + * expired rows from queries. + */ + public function check(string $plainCode, int $maxAttempts = 3): string + { + if ($this->status === 'locked') { + return self::CHECK_LOCKED; + } + + if ($this->hasExpired()) { + return self::CHECK_EXPIRED; + } + + $plainCode = trim($plainCode); + $expected = $this->getMeta('hashed') === true ? static::hashCode($plainCode) : $plainCode; + if (hash_equals((string) $this->code, $expected)) { + return self::CHECK_VALID; + } + + $attempts = (int) $this->getMeta('attempts', 0) + 1; + $this->setMeta('attempts', $attempts); + if ($attempts >= $maxAttempts) { + $this->status = 'locked'; + } + $this->save(); + + return $this->status === 'locked' ? self::CHECK_LOCKED : self::CHECK_INVALID; + } + + /** + * How many wrong codes {@see check()} still allows. + */ + public function attemptsLeft(int $maxAttempts = 3): int + { + return max(0, $maxAttempts - (int) $this->getMeta('attempts', 0)); + } + /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ diff --git a/tests/Unit/Models/VerificationCodeModelTest.php b/tests/Unit/Models/VerificationCodeModelTest.php index 1add9a42..eca78d5f 100644 --- a/tests/Unit/Models/VerificationCodeModelTest.php +++ b/tests/Unit/Models/VerificationCodeModelTest.php @@ -506,3 +506,72 @@ public function message(): never expect(VerificationCode::query()->count())->toBe(0); }); + +it('issues hashed codes that keep only an hmac of the plain code', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:00:00', 'UTC')); + + $subject = verification_code_subject(['uuid' => 'contact-1']); + $issued = VerificationCode::issue($subject, 'fleetops_tracking_access', ['meta' => ['scope' => 'order-1']]); + $stored = VerificationCode::query()->whereKey($issued->uuid)->first(); + + expect($issued->plainCode)->toMatch('/^[1-9][0-9]{5}$/') + ->and($stored->code)->toBe(hash_hmac('sha256', $issued->plainCode, 'base64:test-app-key')) + ->and($stored->code)->not->toBe($issued->plainCode) + ->and(VerificationCode::hashCode($issued->plainCode))->toBe($stored->code) + ->and($stored->for)->toBe('fleetops_tracking_access') + ->and($stored->status)->toBe('active') + ->and($stored->subject_uuid)->toBe('contact-1') + ->and($stored->expires_at->toDateTimeString())->toBe('2026-10-06 09:10:00') + ->and($stored->meta)->toBe(['scope' => 'order-1', 'hashed' => true, 'attempts' => 0]) + ->and($stored->plainCode)->toBeNull(); + + $custom = VerificationCode::issue(null, 'other', [ + 'expireAfter' => Carbon::parse('2026-10-06 09:30:00', 'UTC'), + 'status' => 'pending', + ]); + + expect($custom->status)->toBe('pending') + ->and($custom->subject_uuid)->toBeNull() + ->and($custom->expires_at->toDateTimeString())->toBe('2026-10-06 09:30:00') + ->and($custom->meta)->toBe(['hashed' => true, 'attempts' => 0]); +}); + +it('checks hashed codes, counts wrong attempts and locks on the last one', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + + $issued = VerificationCode::issue(verification_code_subject(), 'fleetops_tracking_access'); + $wrong = $issued->plainCode === '111111' ? '222222' : '111111'; + + expect($issued->check($wrong))->toBe(VerificationCode::CHECK_INVALID) + ->and($issued->attemptsLeft())->toBe(2) + ->and($issued->check(' ' . $issued->plainCode . ' '))->toBe(VerificationCode::CHECK_VALID) + ->and($issued->check($wrong))->toBe(VerificationCode::CHECK_INVALID) + ->and($issued->check($wrong))->toBe(VerificationCode::CHECK_LOCKED) + ->and($issued->attemptsLeft())->toBe(0) + ->and($issued->check($issued->plainCode))->toBe(VerificationCode::CHECK_LOCKED) + ->and(VerificationCode::query()->whereKey($issued->uuid)->first()->status)->toBe('locked') + ->and(VerificationCode::query()->whereKey($issued->uuid)->first()->getMeta('attempts'))->toBe(3); + + $single = VerificationCode::issue(null, 'fleetops_tracking_access'); + expect($single->check($wrong, 1))->toBe(VerificationCode::CHECK_LOCKED); +}); + +it('reports expired codes and still checks plain codes made the old way', function () { + verification_code_model_database(); + config(['app.key' => 'base64:test-app-key']); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:00:00', 'UTC')); + + $issued = VerificationCode::issue(null, 'fleetops_tracking_access'); + Carbon::setTestNow(Carbon::parse('2026-10-06 09:10:00', 'UTC')); + + expect($issued->check($issued->plainCode))->toBe(VerificationCode::CHECK_EXPIRED); + + Carbon::setTestNow(); + $plain = VerificationCode::generateFor(null, 'device_pairing'); + + expect($plain->check((string) $plain->code))->toBe(VerificationCode::CHECK_VALID) + ->and($plain->check('000000'))->toBe(VerificationCode::CHECK_INVALID); +});