feat: enterprise MFA with TOTP, email codes, backup codes, and trusted devices

Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:45:55 +02:00
parent df68aa8aef
commit 948687f27e
32 changed files with 2563 additions and 5 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('mfa_enabled')->default(false)->after('email');
$table->string('mfa_method', 20)->nullable()->after('mfa_enabled');
$table->text('mfa_secret')->nullable()->after('mfa_method');
$table->timestamp('mfa_confirmed_at')->nullable()->after('mfa_secret');
$table->boolean('mfa_enforced')->default(false)->after('mfa_confirmed_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'mfa_enabled',
'mfa_method',
'mfa_secret',
'mfa_confirmed_at',
'mfa_enforced',
]);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mfa_backup_codes', function (Blueprint $table) {
$table->id();
$table->ulid('user_id');
$table->string('code_hash', 64);
$table->boolean('used')->default(false);
$table->timestamp('used_at')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')
->cascadeOnDelete();
$table->index(['user_id', 'used']);
});
}
public function down(): void
{
Schema::dropIfExists('mfa_backup_codes');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trusted_devices', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->ulid('user_id');
$table->string('device_hash', 64);
$table->string('device_name')->nullable();
$table->string('ip_address', 45);
$table->timestamp('trusted_until');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')
->cascadeOnDelete();
$table->index(['user_id', 'device_hash', 'trusted_until']);
});
}
public function down(): void
{
Schema::dropIfExists('trusted_devices');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mfa_email_codes', function (Blueprint $table) {
$table->id();
$table->ulid('user_id');
$table->string('code', 6);
$table->timestamp('expires_at');
$table->boolean('used')->default(false);
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')
->cascadeOnDelete();
$table->index(['user_id', 'code', 'used', 'expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('mfa_email_codes');
}
};