diff --git a/config/mfa.php b/config/mfa.php index c946aed..81fb055 100644 --- a/config/mfa.php +++ b/config/mfa.php @@ -42,5 +42,21 @@ 'regenerate_on_use' => env('MFA_RECOVERY_REGENERATE_ON_USE', false), 'hash_algo' => env('MFA_RECOVERY_HASH_ALGO', 'sha256'), ], + + // Polymorphic owner of MFA records: columns will be model_type/model_id + 'morph' => [ + // Column name prefix; results in `${name}_type` and `${name}_id` + 'name' => env('MFA_MORPH_NAME', 'model'), + + // ID column type for `${name}_id`. + // Supported: unsignedBigInteger (default) | unsignedInteger | bigInteger | integer | string | uuid | ulid + 'type' => env('MFA_MORPH_TYPE', 'unsignedBigInteger'), + + // Length for `${name}_id` when type is "string" + 'string_length' => (int) env('MFA_MORPH_STRING_LENGTH', 40), + + // Length for `${name}_type` column + 'type_length' => (int) env('MFA_MORPH_TYPE_LENGTH', 255), + ], ]; diff --git a/database/migrations/create_mfa_tables.php b/database/migrations/create_mfa_tables.php index c3f1acb..b3670b3 100644 --- a/database/migrations/create_mfa_tables.php +++ b/database/migrations/create_mfa_tables.php @@ -9,32 +9,125 @@ public function up(): void { Schema::create('mfa_methods', function (Blueprint $table) { $table->id(); - $table->string('user_type'); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $morph = config('mfa.morph', []); + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $typeLength = (int) ($morph['type_length'] ?? 255); + $idType = $morph['type'] ?? 'unsignedBigInteger'; + $idStringLength = (int) ($morph['string_length'] ?? 40); + + $table->string($typeColumn, $typeLength); + switch ($idType) { + case 'unsignedInteger': + $table->unsignedInteger($idColumn); + break; + case 'bigInteger': + $table->bigInteger($idColumn); + break; + case 'integer': + $table->integer($idColumn); + break; + case 'string': + $table->string($idColumn, $idStringLength); + break; + case 'uuid': + $table->uuid($idColumn); + break; + case 'ulid': + $table->ulid($idColumn); + break; + case 'unsignedBigInteger': + default: + $table->unsignedBigInteger($idColumn); + break; + } $table->string('method'); // email|sms|totp $table->text('secret')->nullable(); // for totp $table->timestamp('enabled_at')->nullable(); $table->timestamp('last_used_at')->nullable(); $table->timestamps(); - $table->index(['user_type', 'user_id', 'method']); + $table->index([$typeColumn, $idColumn, 'method']); }); Schema::create('mfa_challenges', function (Blueprint $table) { $table->id(); - $table->string('user_type'); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $morph = config('mfa.morph', []); + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $typeLength = (int) ($morph['type_length'] ?? 255); + $idType = $morph['type'] ?? 'unsignedBigInteger'; + $idStringLength = (int) ($morph['string_length'] ?? 40); + + $table->string($typeColumn, $typeLength); + switch ($idType) { + case 'unsignedInteger': + $table->unsignedInteger($idColumn); + break; + case 'bigInteger': + $table->bigInteger($idColumn); + break; + case 'integer': + $table->integer($idColumn); + break; + case 'string': + $table->string($idColumn, $idStringLength); + break; + case 'uuid': + $table->uuid($idColumn); + break; + case 'ulid': + $table->ulid($idColumn); + break; + case 'unsignedBigInteger': + default: + $table->unsignedBigInteger($idColumn); + break; + } $table->string('method'); // email|sms $table->string('code'); $table->timestamp('expires_at'); $table->timestamp('consumed_at')->nullable(); $table->timestamps(); - $table->index(['user_type', 'user_id', 'method']); + $table->index([$typeColumn, $idColumn, 'method']); }); Schema::create('mfa_remembered_devices', function (Blueprint $table) { $table->id(); - $table->string('user_type'); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $morph = config('mfa.morph', []); + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $typeLength = (int) ($morph['type_length'] ?? 255); + $idType = $morph['type'] ?? 'unsignedBigInteger'; + $idStringLength = (int) ($morph['string_length'] ?? 40); + + $table->string($typeColumn, $typeLength); + switch ($idType) { + case 'unsignedInteger': + $table->unsignedInteger($idColumn); + break; + case 'bigInteger': + $table->bigInteger($idColumn); + break; + case 'integer': + $table->integer($idColumn); + break; + case 'string': + $table->string($idColumn, $idStringLength); + break; + case 'uuid': + $table->uuid($idColumn); + break; + case 'ulid': + $table->ulid($idColumn); + break; + case 'unsignedBigInteger': + default: + $table->unsignedBigInteger($idColumn); + break; + } $table->string('token_hash', 64); $table->string('ip_address', 45)->nullable(); $table->string('device_name')->nullable(); @@ -43,19 +136,50 @@ public function up(): void $table->timestamp('expires_at'); $table->timestamps(); - $table->unique(['user_type', 'user_id', 'token_hash'], 'mfa_rd_unique'); - $table->index(['user_type', 'user_id'], 'mfa_rd_user_idx'); + $table->unique([$typeColumn, $idColumn, 'token_hash'], 'mfa_rd_unique'); + $table->index([$typeColumn, $idColumn], 'mfa_rd_user_idx'); }); Schema::create('mfa_recovery_codes', function (Blueprint $table) { $table->id(); - $table->string('user_type'); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $morph = config('mfa.morph', []); + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $typeLength = (int) ($morph['type_length'] ?? 255); + $idType = $morph['type'] ?? 'unsignedBigInteger'; + $idStringLength = (int) ($morph['string_length'] ?? 40); + + $table->string($typeColumn, $typeLength); + switch ($idType) { + case 'unsignedInteger': + $table->unsignedInteger($idColumn); + break; + case 'bigInteger': + $table->bigInteger($idColumn); + break; + case 'integer': + $table->integer($idColumn); + break; + case 'string': + $table->string($idColumn, $idStringLength); + break; + case 'uuid': + $table->uuid($idColumn); + break; + case 'ulid': + $table->ulid($idColumn); + break; + case 'unsignedBigInteger': + default: + $table->unsignedBigInteger($idColumn); + break; + } $table->string('code_hash', 128); $table->timestamp('used_at')->nullable(); $table->timestamps(); - $table->index(['user_type', 'user_id'], 'mfa_rc_user_idx'); + $table->index([$typeColumn, $idColumn], 'mfa_rc_user_idx'); }); } diff --git a/src/MFA.php b/src/MFA.php index dc237fe..08fd31a 100644 --- a/src/MFA.php +++ b/src/MFA.php @@ -83,8 +83,12 @@ public function issueChallenge(Authenticatable $user, string $method): ?MfaChall $code = str_pad((string) random_int(0, (10 ** $codeLength) - 1), $codeLength, '0', STR_PAD_LEFT); $challenge = new MfaChallenge(); - $challenge->user_type = get_class($user); - $challenge->user_id = $user->getAuthIdentifier(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $challenge->setAttribute($typeColumn, get_class($user)); + $challenge->setAttribute($idColumn, $user->getAuthIdentifier()); $challenge->method = $method; $challenge->code = $code; $challenge->expires_at = Carbon::now()->addSeconds($ttlSeconds); @@ -120,9 +124,13 @@ public function verifyChallenge(Authenticatable $user, string $method, string $c { $now = Carbon::now(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; $challenge = MfaChallenge::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->where('method', strtolower($method)) ->whereNull('consumed_at') ->where('expires_at', '>', $now) @@ -170,9 +178,13 @@ public function shouldSkipVerification(Authenticatable $user, ?string $token): b $hash = hash('sha256', $token); $now = Carbon::now(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; $record = MfaRememberedDevice::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->where('token_hash', $hash) ->where('expires_at', '>', $now) ->first(); @@ -206,8 +218,12 @@ public function rememberDevice(Authenticatable $user, ?int $lifetimeDays = null, $hash = hash('sha256', $plainToken); $record = new MfaRememberedDevice(); - $record->user_type = get_class($user); - $record->user_id = $user->getAuthIdentifier(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $record->setAttribute($typeColumn, get_class($user)); + $record->setAttribute($idColumn, $user->getAuthIdentifier()); $record->token_hash = $hash; $record->device_name = $deviceName; $request = app('request'); @@ -243,9 +259,13 @@ public function makeRememberCookie(string $token, ?int $lifetimeDays = null): Co public function forgetRememberedDevice(Authenticatable $user, string $token): int { $hash = hash('sha256', $token); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; return MfaRememberedDevice::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->where('token_hash', $hash) ->delete(); } @@ -255,8 +275,12 @@ public function enableMethod(Authenticatable $user, string $method, array $attri $record = $this->getMethod($user, $method); if (! $record) { $record = new MfaMethod(); - $record->user_type = get_class($user); - $record->user_id = $user->getAuthIdentifier(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $record->setAttribute($typeColumn, get_class($user)); + $record->setAttribute($idColumn, $user->getAuthIdentifier()); $record->method = strtolower($method); } @@ -288,9 +312,13 @@ public function isEnabled(Authenticatable $user, string $method): bool public function getMethod(Authenticatable $user, string $method): ?MfaMethod { + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; return MfaMethod::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->where('method', strtolower($method)) ->first(); } @@ -305,9 +333,13 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null, $length = $length ?? (int) Arr::get($this->config, 'recovery.code_length', 10); if ($replaceExisting) { + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; MfaRecoveryCode::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->delete(); } @@ -317,8 +349,12 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null, $hash = $this->hashRecoveryCode($code); $record = new MfaRecoveryCode(); - $record->user_type = get_class($user); - $record->user_id = $user->getAuthIdentifier(); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; + $record->setAttribute($typeColumn, get_class($user)); + $record->setAttribute($idColumn, $user->getAuthIdentifier()); $record->code_hash = $hash; $record->used_at = null; $record->save(); @@ -333,9 +369,13 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null, public function verifyRecoveryCode(Authenticatable $user, string $code): bool { $hash = $this->hashRecoveryCode($code); + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; $record = MfaRecoveryCode::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->whereNull('used_at') ->where('code_hash', $hash) ->first(); @@ -359,9 +399,13 @@ public function verifyRecoveryCode(Authenticatable $user, string $code): bool /** Get remaining (unused) recovery codes count for the user. */ public function getRemainingRecoveryCodesCount(Authenticatable $user): int { + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; return MfaRecoveryCode::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->whereNull('used_at') ->count(); } @@ -369,9 +413,13 @@ public function getRemainingRecoveryCodesCount(Authenticatable $user): int /** Delete all recovery codes for the user. Returns number deleted. */ public function clearRecoveryCodes(Authenticatable $user): int { + $morph = $this->config['morph'] ?? []; + $morphName = $morph['name'] ?? 'model'; + $typeColumn = $morphName . '_type'; + $idColumn = $morphName . '_id'; return MfaRecoveryCode::query() - ->where('user_type', get_class($user)) - ->where('user_id', $user->getAuthIdentifier()) + ->where($typeColumn, get_class($user)) + ->where($idColumn, $user->getAuthIdentifier()) ->delete(); } diff --git a/src/Models/MfaChallenge.php b/src/Models/MfaChallenge.php index e2e82e8..e04dc60 100644 --- a/src/Models/MfaChallenge.php +++ b/src/Models/MfaChallenge.php @@ -14,5 +14,12 @@ class MfaChallenge extends Model 'expires_at' => 'datetime', 'consumed_at' => 'datetime', ]; + + public function model() + { + $morph = config('mfa.morph', []); + $name = $morph['name'] ?? 'model'; + return $this->morphTo(__FUNCTION__, $name . '_type', $name . '_id'); + } } diff --git a/src/Models/MfaMethod.php b/src/Models/MfaMethod.php index c1957a6..ebaa44b 100644 --- a/src/Models/MfaMethod.php +++ b/src/Models/MfaMethod.php @@ -15,5 +15,12 @@ class MfaMethod extends Model 'last_used_at' => 'datetime', 'secret' => 'encrypted', ]; + + public function model() + { + $morph = config('mfa.morph', []); + $name = $morph['name'] ?? 'model'; + return $this->morphTo(__FUNCTION__, $name . '_type', $name . '_id'); + } } diff --git a/src/Models/MfaRecoveryCode.php b/src/Models/MfaRecoveryCode.php index 83937e3..381b125 100644 --- a/src/Models/MfaRecoveryCode.php +++ b/src/Models/MfaRecoveryCode.php @@ -13,5 +13,12 @@ class MfaRecoveryCode extends Model protected $casts = [ 'used_at' => 'datetime', ]; + + public function model() + { + $morph = config('mfa.morph', []); + $name = $morph['name'] ?? 'model'; + return $this->morphTo(__FUNCTION__, $name . '_type', $name . '_id'); + } } diff --git a/src/Models/MfaRememberedDevice.php b/src/Models/MfaRememberedDevice.php index 2aa23e4..5effb1d 100644 --- a/src/Models/MfaRememberedDevice.php +++ b/src/Models/MfaRememberedDevice.php @@ -14,5 +14,12 @@ class MfaRememberedDevice extends Model 'last_used_at' => 'datetime', 'expires_at' => 'datetime', ]; + + public function model() + { + $morph = config('mfa.morph', []); + $name = $morph['name'] ?? 'model'; + return $this->morphTo(__FUNCTION__, $name . '_type', $name . '_id'); + } }