|
10 | 10 | use CodingLibs\MFA\Models\MfaChallenge; |
11 | 11 | use CodingLibs\MFA\Models\MfaMethod; |
12 | 12 | use CodingLibs\MFA\Models\MfaRememberedDevice; |
| 13 | +use CodingLibs\MFA\Models\MfaRecoveryCode; |
13 | 14 | use CodingLibs\MFA\Totp\GoogleTotp; |
14 | 15 | use Illuminate\Contracts\Auth\Authenticatable; |
15 | 16 | use Illuminate\Http\Request; |
@@ -293,5 +294,103 @@ public function getMethod(Authenticatable $user, string $method): ?MfaMethod |
293 | 294 | ->where('method', strtolower($method)) |
294 | 295 | ->first(); |
295 | 296 | } |
| 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 | + } |
296 | 395 | } |
297 | 396 |
|
0 commit comments