Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions src/Models/VerificationCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
69 changes: 69 additions & 0 deletions tests/Unit/Models/VerificationCodeModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading