Skip to content

Commit ff05354

Browse files
authored
Merge pull request #21
Add recovery code feature
2 parents 1e6b14f + 257dce2 commit ff05354

File tree

6 files changed

+169
-0
lines changed

6 files changed

+169
-0
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Features
2323
- Google Authenticator compatible **TOTP** (RFC 6238) setup and verification
2424
- Built-in QR code generation to display TOTP provisioning URI (uses bacon/bacon-qr-code)
2525
- Remember device support via secure, hashed tokens stored in `mfa_remembered_devices`
26+
- Recovery Codes: generate, verify, and manage one-time backup codes
2627
- Simple API via `MFA` facade/service for issuing and verifying codes
2728
- Publishable config and migrations; encrypted storage of TOTP secret
2829
- Extendable channel system to add providers like WhatsApp, Twilio, etc.
@@ -63,6 +64,16 @@ $cookie = $result['cookie']; // Symfony Cookie instance — attach to response
6364

6465
// Later, skip MFA if remembered device cookie is valid
6566
$shouldSkip = MFA::shouldSkipVerification(auth()->user(), MFA::getRememberTokenFromRequest(request()));
67+
68+
// Recovery Codes
69+
// Generate a fresh set (returns plaintext codes to show once)
70+
$codes = MFA::generateRecoveryCodes(auth()->user());
71+
// Verify and consume a recovery code
72+
$ok = MFA::verifyRecoveryCode(auth()->user(), $inputCode);
73+
// Count remaining unused codes
74+
$remaining = MFA::getRemainingRecoveryCodesCount(auth()->user());
75+
// Clear all codes
76+
$deleted = MFA::clearRecoveryCodes(auth()->user());
6677
```
6778

6879
Remember Devices (Optional)
@@ -92,6 +103,12 @@ Configuration
92103
- cookie: cookie name (default `mfa_rd`)
93104
- lifetime_days: validity window (default 30)
94105
- path, domain, secure, http_only, same_site
106+
- **recovery**:
107+
- enabled (bool, default true)
108+
- codes_count: number of codes to generate (default 10)
109+
- code_length: length of each code (default 10)
110+
- regenerate_on_use: whether to auto-regenerate when consumed (default false)
111+
- hash_algo: hashing algorithm for stored codes (default `sha256`)
95112

96113
Environment variables (examples)
97114
```
@@ -115,13 +132,20 @@ MFA_REMEMBER_DOMAIN=
115132
MFA_REMEMBER_SECURE=null
116133
MFA_REMEMBER_HTTP_ONLY=true
117134
MFA_REMEMBER_SAME_SITE=lax
135+
136+
MFA_RECOVERY_ENABLED=true
137+
MFA_RECOVERY_CODES_COUNT=10
138+
MFA_RECOVERY_CODE_LENGTH=10
139+
MFA_RECOVERY_REGENERATE_ON_USE=false
140+
MFA_RECOVERY_HASH_ALGO=sha256
118141
```
119142

120143
Database
121144
- Publishing migrations creates tables:
122145
- `mfa_methods`: tracks enabled MFA methods per user; stores encrypted TOTP `secret`
123146
- `mfa_challenges`: stores pending OTP codes for email/sms with expiry and consumed_at
124147
- `mfa_remembered_devices`: stores hashed tokens for device recognition with IP, UA, and expiry
148+
- `mfa_recovery_codes`: stores hashed recovery codes and usage timestamp
125149

126150
API Overview (Facade `MFA`)
127151
- **issueChallenge(Authenticatable $user, string $method): ?MfaChallenge**
@@ -140,6 +164,11 @@ API Overview (Facade `MFA`)
140164
- **shouldSkipVerification(Authenticatable $user, ?string $token): bool**
141165
- **makeRememberCookie(string $token, ?int $lifetimeDays = null): Cookie**
142166
- **forgetRememberedDevice(Authenticatable $user, string $token): int**
167+
- Recovery codes:
168+
- **generateRecoveryCodes(Authenticatable $user, ?int $count = null, ?int $length = null, bool $replaceExisting = true): array** returns plaintext codes
169+
- **verifyRecoveryCode(Authenticatable $user, string $code): bool**
170+
- **getRemainingRecoveryCodesCount(Authenticatable $user): int**
171+
- **clearRecoveryCodes(Authenticatable $user): int**
143172

144173
Creating a Custom MFA Channel
145174
Steps

config/mfa.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,13 @@
3434
'http_only' => env('MFA_REMEMBER_HTTP_ONLY', true),
3535
'same_site' => env('MFA_REMEMBER_SAME_SITE', 'lax'), // lax|strict|none
3636
],
37+
38+
'recovery' => [
39+
'enabled' => env('MFA_RECOVERY_ENABLED', true),
40+
'codes_count' => (int) env('MFA_RECOVERY_CODES_COUNT', 10),
41+
'code_length' => (int) env('MFA_RECOVERY_CODE_LENGTH', 10),
42+
'regenerate_on_use' => env('MFA_RECOVERY_REGENERATE_ON_USE', false),
43+
'hash_algo' => env('MFA_RECOVERY_HASH_ALGO', 'sha256'),
44+
],
3745
];
3846

database/migrations/create_mfa_tables.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,22 @@ public function up(): void
4646
$table->unique(['user_type', 'user_id', 'token_hash'], 'mfa_rd_unique');
4747
$table->index(['user_type', 'user_id'], 'mfa_rd_user_idx');
4848
});
49+
50+
Schema::create('mfa_recovery_codes', function (Blueprint $table) {
51+
$table->id();
52+
$table->string('user_type');
53+
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
54+
$table->string('code_hash', 128);
55+
$table->timestamp('used_at')->nullable();
56+
$table->timestamps();
57+
58+
$table->index(['user_type', 'user_id'], 'mfa_rc_user_idx');
59+
});
4960
}
5061

5162
public function down(): void
5263
{
64+
Schema::dropIfExists('mfa_recovery_codes');
5365
Schema::dropIfExists('mfa_remembered_devices');
5466
Schema::dropIfExists('mfa_challenges');
5567
Schema::dropIfExists('mfa_methods');

src/Facades/MFA.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
* @method static bool disableMethod(\Illuminate\Contracts\Auth\Authenticatable $user, string $method)
2828
* @method static bool isEnabled(\Illuminate\Contracts\Auth\Authenticatable $user, string $method)
2929
* @method static ?\CodingLibs\MFA\Models\MfaMethod getMethod(\Illuminate\Contracts\Auth\Authenticatable $user, string $method)
30+
* @method static array generateRecoveryCodes(\Illuminate\Contracts\Auth\Authenticatable $user, ?int $count = null, ?int $length = null, bool $replaceExisting = true)
31+
* @method static bool verifyRecoveryCode(\Illuminate\Contracts\Auth\Authenticatable $user, string $code)
32+
* @method static int getRemainingRecoveryCodesCount(\Illuminate\Contracts\Auth\Authenticatable $user)
33+
* @method static int clearRecoveryCodes(\Illuminate\Contracts\Auth\Authenticatable $user)
3034
*
3135
* @mixin \CodingLibs\MFA\MFA
3236
* @see \CodingLibs\MFA\MFA

src/MFA.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use CodingLibs\MFA\Models\MfaChallenge;
1111
use CodingLibs\MFA\Models\MfaMethod;
1212
use CodingLibs\MFA\Models\MfaRememberedDevice;
13+
use CodingLibs\MFA\Models\MfaRecoveryCode;
1314
use CodingLibs\MFA\Totp\GoogleTotp;
1415
use Illuminate\Contracts\Auth\Authenticatable;
1516
use Illuminate\Http\Request;
@@ -293,5 +294,103 @@ public function getMethod(Authenticatable $user, string $method): ?MfaMethod
293294
->where('method', strtolower($method))
294295
->first();
295296
}
297+
298+
/**
299+
* Generate and persist recovery codes for the given user.
300+
* Returns the plaintext codes; hashes are stored in DB.
301+
*/
302+
public function generateRecoveryCodes(Authenticatable $user, ?int $count = null, ?int $length = null, bool $replaceExisting = true): array
303+
{
304+
$count = $count ?? (int) Arr::get($this->config, 'recovery.codes_count', 10);
305+
$length = $length ?? (int) Arr::get($this->config, 'recovery.code_length', 10);
306+
307+
if ($replaceExisting) {
308+
MfaRecoveryCode::query()
309+
->where('user_type', get_class($user))
310+
->where('user_id', $user->getAuthIdentifier())
311+
->delete();
312+
}
313+
314+
$plaintextCodes = [];
315+
for ($i = 0; $i < $count; $i++) {
316+
$code = $this->generateReadableCode($length);
317+
$hash = $this->hashRecoveryCode($code);
318+
319+
$record = new MfaRecoveryCode();
320+
$record->user_type = get_class($user);
321+
$record->user_id = $user->getAuthIdentifier();
322+
$record->code_hash = $hash;
323+
$record->used_at = null;
324+
$record->save();
325+
326+
$plaintextCodes[] = $code;
327+
}
328+
329+
return $plaintextCodes;
330+
}
331+
332+
/** Verify and consume a recovery code for the user. */
333+
public function verifyRecoveryCode(Authenticatable $user, string $code): bool
334+
{
335+
$hash = $this->hashRecoveryCode($code);
336+
$record = MfaRecoveryCode::query()
337+
->where('user_type', get_class($user))
338+
->where('user_id', $user->getAuthIdentifier())
339+
->whereNull('used_at')
340+
->where('code_hash', $hash)
341+
->first();
342+
343+
if (! $record) {
344+
return false;
345+
}
346+
347+
$record->used_at = Carbon::now();
348+
$record->save();
349+
350+
if ((bool) Arr::get($this->config, 'recovery.regenerate_on_use', false)) {
351+
$length = (int) Arr::get($this->config, 'recovery.code_length', 10);
352+
// Generate a single replacement code to keep the pool size steady
353+
$this->generateRecoveryCodes($user, 1, $length, false);
354+
}
355+
356+
return true;
357+
}
358+
359+
/** Get remaining (unused) recovery codes count for the user. */
360+
public function getRemainingRecoveryCodesCount(Authenticatable $user): int
361+
{
362+
return MfaRecoveryCode::query()
363+
->where('user_type', get_class($user))
364+
->where('user_id', $user->getAuthIdentifier())
365+
->whereNull('used_at')
366+
->count();
367+
}
368+
369+
/** Delete all recovery codes for the user. Returns number deleted. */
370+
public function clearRecoveryCodes(Authenticatable $user): int
371+
{
372+
return MfaRecoveryCode::query()
373+
->where('user_type', get_class($user))
374+
->where('user_id', $user->getAuthIdentifier())
375+
->delete();
376+
}
377+
378+
protected function generateReadableCode(int $length): string
379+
{
380+
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // exclude ambiguous chars
381+
$maxIndex = strlen($alphabet) - 1;
382+
$code = '';
383+
for ($i = 0; $i < $length; $i++) {
384+
$idx = random_int(0, $maxIndex);
385+
$code .= $alphabet[$idx];
386+
}
387+
return $code;
388+
}
389+
390+
protected function hashRecoveryCode(string $code): string
391+
{
392+
$algo = (string) Arr::get($this->config, 'recovery.hash_algo', 'sha256');
393+
return hash($algo, $code);
394+
}
296395
}
297396

src/Models/MfaRecoveryCode.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
7+
class MfaRecoveryCode extends Model
8+
{
9+
protected $table = 'mfa_recovery_codes';
10+
11+
protected $guarded = [];
12+
13+
protected $casts = [
14+
'used_at' => 'datetime',
15+
];
16+
}
17+

0 commit comments

Comments
 (0)