Skip to content

Commit bda2052

Browse files
authored
Merge pull request #23
Document and test recovery code feature
2 parents e8674d5 + 950ee29 commit bda2052

File tree

5 files changed

+169
-3
lines changed

5 files changed

+169
-3
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,46 @@ Remember Devices (Optional)
8282
- On subsequent requests, use `MFA::shouldSkipVerification($user, MFA::getRememberTokenFromRequest($request))`
8383
- To revoke a remembered device, call `MFA::forgetRememberedDevice($user, $token)`
8484

85+
Recovery Codes
86+
- What they are: single‑use backup codes that let users complete MFA when they cannot access their primary factor (e.g., lost phone or no network).
87+
- Storage and security:
88+
- Plaintext codes are returned only once at generation time; only their hashes are stored in `mfa_recovery_codes`.
89+
- Hashing algorithm is configurable via `mfa.recovery.hash_algo` (default `sha256`).
90+
- Codes are marked as used at first successful verification and cannot be reused.
91+
- Generating and displaying to the user:
92+
```php
93+
// Generate N codes (defaults come from config)
94+
$codes = MFA::generateRecoveryCodes($user); // array of plaintext codes
95+
96+
// Show these codes once to the user and prompt them to store securely
97+
// e.g., render as a list and offer a download/print option
98+
```
99+
- Verifying a code and optional regeneration-on-use:
100+
```php
101+
if (MFA::verifyRecoveryCode($user, $input)) {
102+
// Success: log user in and consider rotating codes if desired
103+
}
104+
```
105+
- Pool size maintenance: set `mfa.recovery.regenerate_on_use = true` to automatically replace a consumed code with a new one so the remaining count stays steady.
106+
- Managing codes:
107+
```php
108+
// Count remaining unused codes
109+
$remaining = MFA::getRemainingRecoveryCodesCount($user);
110+
111+
// Replace all existing codes with a new set
112+
$fresh = MFA::generateRecoveryCodes($user); // replaceExisting=true by default
113+
114+
// Append without deleting existing codes
115+
$extra = MFA::generateRecoveryCodes($user, count: 2, replaceExisting: false);
116+
117+
// Clear all codes
118+
$deleted = MFA::clearRecoveryCodes($user);
119+
```
120+
- UX recommendations:
121+
- Require the user to confirm they’ve saved the codes before leaving the setup screen.
122+
- Offer copy, download (txt), and print actions. Avoid storing plaintext on your servers.
123+
- Warn that each code is one-time and will be invalid after use.
124+
85125
Configuration
86126
- See `config/mfa.php` for all options. Key settings:
87127
- **code_length**: OTP digits for email/sms (default 6)

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
},
2626
"autoload-dev": {
2727
"psr-4": {
28-
"CodingLibs\\MFA\\Tests\\": "tests/"
28+
"CodingLibs\\MFA\\Tests\\": "tests/",
29+
"Tests\\": "tests/"
2930
}
3031
},
3132
"extra": {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
use CodingLibs\MFA\Models\MfaRecoveryCode;
4+
use Illuminate\Contracts\Auth\Authenticatable;
5+
6+
uses(Tests\TestCase::class);
7+
8+
class FakeUser implements Authenticatable
9+
{
10+
public function __construct(public int|string $id) {}
11+
public function getAuthIdentifierName()
12+
{
13+
return 'id';
14+
}
15+
public function getAuthIdentifier()
16+
{
17+
return $this->id;
18+
}
19+
public function getAuthPassword()
20+
{
21+
return '';
22+
}
23+
public function getRememberToken()
24+
{
25+
return null;
26+
}
27+
public function setRememberToken($value): void {}
28+
public function getRememberTokenName()
29+
{
30+
return 'remember_token';
31+
}
32+
}
33+
34+
it('generates recovery codes and persists hashed versions', function () {
35+
$user = new FakeUser(1);
36+
$mfa = app(\CodingLibs\MFA\MFA::class);
37+
38+
$codes = $mfa->generateRecoveryCodes($user, 5, 12);
39+
expect($codes)->toHaveCount(5);
40+
foreach ($codes as $code) {
41+
expect(strlen($code))->toBe(12);
42+
}
43+
44+
$remaining = $mfa->getRemainingRecoveryCodesCount($user);
45+
expect($remaining)->toBe(5);
46+
47+
$records = MfaRecoveryCode::query()
48+
->where('model_type', get_class($user))
49+
->where('model_id', $user->getAuthIdentifier())
50+
->get();
51+
expect($records)->toHaveCount(5);
52+
foreach ($records as $rec) {
53+
// Ensure hashes are stored, not plaintext
54+
expect($codes)->not->toContain($rec->code_hash);
55+
expect($rec->used_at)->toBeNull();
56+
}
57+
});
58+
59+
it('verifies and consumes a recovery code', function () {
60+
$user = new FakeUser(2);
61+
$mfa = app(\CodingLibs\MFA\MFA::class);
62+
63+
$codes = $mfa->generateRecoveryCodes($user, 3, 10);
64+
$ok = $mfa->verifyRecoveryCode($user, $codes[0]);
65+
expect($ok)->toBeTrue();
66+
67+
// cannot reuse the same code
68+
$okAgain = $mfa->verifyRecoveryCode($user, $codes[0]);
69+
expect($okAgain)->toBeFalse();
70+
71+
$remaining = $mfa->getRemainingRecoveryCodesCount($user);
72+
expect($remaining)->toBe(2);
73+
});
74+
75+
it('regenerates on use when enabled', function () {
76+
config(['mfa.recovery.regenerate_on_use' => true]);
77+
$user = new FakeUser(3);
78+
$mfa = app(\CodingLibs\MFA\MFA::class);
79+
80+
$codes = $mfa->generateRecoveryCodes($user, 4, 8);
81+
$ok = $mfa->verifyRecoveryCode($user, $codes[1]);
82+
expect($ok)->toBeTrue();
83+
84+
// Pool size remains steady due to auto-regeneration
85+
$remaining = $mfa->getRemainingRecoveryCodesCount($user);
86+
expect($remaining)->toBe(4);
87+
});
88+
89+
it('clearRecoveryCodes deletes all codes', function () {
90+
$user = new FakeUser(4);
91+
$mfa = app(\CodingLibs\MFA\MFA::class);
92+
93+
$mfa->generateRecoveryCodes($user, 2, 10);
94+
$deleted = $mfa->clearRecoveryCodes($user);
95+
expect($deleted)->toBeGreaterThanOrEqual(2);
96+
97+
$remaining = $mfa->getRemainingRecoveryCodesCount($user);
98+
expect($remaining)->toBe(0);
99+
});
100+

tests/Pest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22

33
/* Minimal Pest bootstrap for unit tests */
44
uses()->group('unit');
5+
6+
// Enable Laravel Testbench for Feature tests
7+
uses(Tests\TestCase::class)->in('Feature');

tests/TestCase.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,31 @@
22

33
namespace Tests;
44

5-
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
5+
use CodingLibs\MFA\MFAServiceProvider;
6+
use Orchestra\Testbench\TestCase as BaseTestCase;
67

78
abstract class TestCase extends BaseTestCase
89
{
9-
//
10+
protected function getPackageProviders($app)
11+
{
12+
return [MFAServiceProvider::class];
13+
}
14+
15+
protected function getEnvironmentSetUp($app): void
16+
{
17+
config()->set('database.default', 'testing');
18+
config()->set('database.connections.testing', [
19+
'driver' => 'sqlite',
20+
'database' => ':memory:',
21+
'prefix' => '',
22+
]);
23+
}
24+
25+
protected function setUp(): void
26+
{
27+
parent::setUp();
28+
// Run package migrations
29+
$migration = require __DIR__ . '/../database/migrations/create_mfa_tables.php';
30+
$migration->up();
31+
}
1032
}

0 commit comments

Comments
 (0)