Skip to content

Commit e8674d5

Browse files
authored
Merge pull request #22
Implement configurable polymorphic relations
2 parents ff05354 + a69c190 commit e8674d5

File tree

7 files changed

+253
-37
lines changed

7 files changed

+253
-37
lines changed

config/mfa.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,21 @@
4242
'regenerate_on_use' => env('MFA_RECOVERY_REGENERATE_ON_USE', false),
4343
'hash_algo' => env('MFA_RECOVERY_HASH_ALGO', 'sha256'),
4444
],
45+
46+
// Polymorphic owner of MFA records: columns will be model_type/model_id
47+
'morph' => [
48+
// Column name prefix; results in `${name}_type` and `${name}_id`
49+
'name' => env('MFA_MORPH_NAME', 'model'),
50+
51+
// ID column type for `${name}_id`.
52+
// Supported: unsignedBigInteger (default) | unsignedInteger | bigInteger | integer | string | uuid | ulid
53+
'type' => env('MFA_MORPH_TYPE', 'unsignedBigInteger'),
54+
55+
// Length for `${name}_id` when type is "string"
56+
'string_length' => (int) env('MFA_MORPH_STRING_LENGTH', 40),
57+
58+
// Length for `${name}_type` column
59+
'type_length' => (int) env('MFA_MORPH_TYPE_LENGTH', 255),
60+
],
4561
];
4662

database/migrations/create_mfa_tables.php

Lines changed: 137 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,125 @@ public function up(): void
99
{
1010
Schema::create('mfa_methods', function (Blueprint $table) {
1111
$table->id();
12-
$table->string('user_type');
13-
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
12+
$morph = config('mfa.morph', []);
13+
$morphName = $morph['name'] ?? 'model';
14+
$typeColumn = $morphName . '_type';
15+
$idColumn = $morphName . '_id';
16+
$typeLength = (int) ($morph['type_length'] ?? 255);
17+
$idType = $morph['type'] ?? 'unsignedBigInteger';
18+
$idStringLength = (int) ($morph['string_length'] ?? 40);
19+
20+
$table->string($typeColumn, $typeLength);
21+
switch ($idType) {
22+
case 'unsignedInteger':
23+
$table->unsignedInteger($idColumn);
24+
break;
25+
case 'bigInteger':
26+
$table->bigInteger($idColumn);
27+
break;
28+
case 'integer':
29+
$table->integer($idColumn);
30+
break;
31+
case 'string':
32+
$table->string($idColumn, $idStringLength);
33+
break;
34+
case 'uuid':
35+
$table->uuid($idColumn);
36+
break;
37+
case 'ulid':
38+
$table->ulid($idColumn);
39+
break;
40+
case 'unsignedBigInteger':
41+
default:
42+
$table->unsignedBigInteger($idColumn);
43+
break;
44+
}
1445
$table->string('method'); // email|sms|totp
1546
$table->text('secret')->nullable(); // for totp
1647
$table->timestamp('enabled_at')->nullable();
1748
$table->timestamp('last_used_at')->nullable();
1849
$table->timestamps();
19-
$table->index(['user_type', 'user_id', 'method']);
50+
$table->index([$typeColumn, $idColumn, 'method']);
2051
});
2152

2253
Schema::create('mfa_challenges', function (Blueprint $table) {
2354
$table->id();
24-
$table->string('user_type');
25-
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
55+
$morph = config('mfa.morph', []);
56+
$morphName = $morph['name'] ?? 'model';
57+
$typeColumn = $morphName . '_type';
58+
$idColumn = $morphName . '_id';
59+
$typeLength = (int) ($morph['type_length'] ?? 255);
60+
$idType = $morph['type'] ?? 'unsignedBigInteger';
61+
$idStringLength = (int) ($morph['string_length'] ?? 40);
62+
63+
$table->string($typeColumn, $typeLength);
64+
switch ($idType) {
65+
case 'unsignedInteger':
66+
$table->unsignedInteger($idColumn);
67+
break;
68+
case 'bigInteger':
69+
$table->bigInteger($idColumn);
70+
break;
71+
case 'integer':
72+
$table->integer($idColumn);
73+
break;
74+
case 'string':
75+
$table->string($idColumn, $idStringLength);
76+
break;
77+
case 'uuid':
78+
$table->uuid($idColumn);
79+
break;
80+
case 'ulid':
81+
$table->ulid($idColumn);
82+
break;
83+
case 'unsignedBigInteger':
84+
default:
85+
$table->unsignedBigInteger($idColumn);
86+
break;
87+
}
2688
$table->string('method'); // email|sms
2789
$table->string('code');
2890
$table->timestamp('expires_at');
2991
$table->timestamp('consumed_at')->nullable();
3092
$table->timestamps();
31-
$table->index(['user_type', 'user_id', 'method']);
93+
$table->index([$typeColumn, $idColumn, 'method']);
3294
});
3395

3496
Schema::create('mfa_remembered_devices', function (Blueprint $table) {
3597
$table->id();
36-
$table->string('user_type');
37-
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
98+
$morph = config('mfa.morph', []);
99+
$morphName = $morph['name'] ?? 'model';
100+
$typeColumn = $morphName . '_type';
101+
$idColumn = $morphName . '_id';
102+
$typeLength = (int) ($morph['type_length'] ?? 255);
103+
$idType = $morph['type'] ?? 'unsignedBigInteger';
104+
$idStringLength = (int) ($morph['string_length'] ?? 40);
105+
106+
$table->string($typeColumn, $typeLength);
107+
switch ($idType) {
108+
case 'unsignedInteger':
109+
$table->unsignedInteger($idColumn);
110+
break;
111+
case 'bigInteger':
112+
$table->bigInteger($idColumn);
113+
break;
114+
case 'integer':
115+
$table->integer($idColumn);
116+
break;
117+
case 'string':
118+
$table->string($idColumn, $idStringLength);
119+
break;
120+
case 'uuid':
121+
$table->uuid($idColumn);
122+
break;
123+
case 'ulid':
124+
$table->ulid($idColumn);
125+
break;
126+
case 'unsignedBigInteger':
127+
default:
128+
$table->unsignedBigInteger($idColumn);
129+
break;
130+
}
38131
$table->string('token_hash', 64);
39132
$table->string('ip_address', 45)->nullable();
40133
$table->string('device_name')->nullable();
@@ -43,19 +136,50 @@ public function up(): void
43136
$table->timestamp('expires_at');
44137
$table->timestamps();
45138

46-
$table->unique(['user_type', 'user_id', 'token_hash'], 'mfa_rd_unique');
47-
$table->index(['user_type', 'user_id'], 'mfa_rd_user_idx');
139+
$table->unique([$typeColumn, $idColumn, 'token_hash'], 'mfa_rd_unique');
140+
$table->index([$typeColumn, $idColumn], 'mfa_rd_user_idx');
48141
});
49142

50143
Schema::create('mfa_recovery_codes', function (Blueprint $table) {
51144
$table->id();
52-
$table->string('user_type');
53-
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
145+
$morph = config('mfa.morph', []);
146+
$morphName = $morph['name'] ?? 'model';
147+
$typeColumn = $morphName . '_type';
148+
$idColumn = $morphName . '_id';
149+
$typeLength = (int) ($morph['type_length'] ?? 255);
150+
$idType = $morph['type'] ?? 'unsignedBigInteger';
151+
$idStringLength = (int) ($morph['string_length'] ?? 40);
152+
153+
$table->string($typeColumn, $typeLength);
154+
switch ($idType) {
155+
case 'unsignedInteger':
156+
$table->unsignedInteger($idColumn);
157+
break;
158+
case 'bigInteger':
159+
$table->bigInteger($idColumn);
160+
break;
161+
case 'integer':
162+
$table->integer($idColumn);
163+
break;
164+
case 'string':
165+
$table->string($idColumn, $idStringLength);
166+
break;
167+
case 'uuid':
168+
$table->uuid($idColumn);
169+
break;
170+
case 'ulid':
171+
$table->ulid($idColumn);
172+
break;
173+
case 'unsignedBigInteger':
174+
default:
175+
$table->unsignedBigInteger($idColumn);
176+
break;
177+
}
54178
$table->string('code_hash', 128);
55179
$table->timestamp('used_at')->nullable();
56180
$table->timestamps();
57181

58-
$table->index(['user_type', 'user_id'], 'mfa_rc_user_idx');
182+
$table->index([$typeColumn, $idColumn], 'mfa_rc_user_idx');
59183
});
60184
}
61185

src/MFA.php

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,12 @@ public function issueChallenge(Authenticatable $user, string $method): ?MfaChall
8383
$code = str_pad((string) random_int(0, (10 ** $codeLength) - 1), $codeLength, '0', STR_PAD_LEFT);
8484

8585
$challenge = new MfaChallenge();
86-
$challenge->user_type = get_class($user);
87-
$challenge->user_id = $user->getAuthIdentifier();
86+
$morph = $this->config['morph'] ?? [];
87+
$morphName = $morph['name'] ?? 'model';
88+
$typeColumn = $morphName . '_type';
89+
$idColumn = $morphName . '_id';
90+
$challenge->setAttribute($typeColumn, get_class($user));
91+
$challenge->setAttribute($idColumn, $user->getAuthIdentifier());
8892
$challenge->method = $method;
8993
$challenge->code = $code;
9094
$challenge->expires_at = Carbon::now()->addSeconds($ttlSeconds);
@@ -120,9 +124,13 @@ public function verifyChallenge(Authenticatable $user, string $method, string $c
120124
{
121125
$now = Carbon::now();
122126

127+
$morph = $this->config['morph'] ?? [];
128+
$morphName = $morph['name'] ?? 'model';
129+
$typeColumn = $morphName . '_type';
130+
$idColumn = $morphName . '_id';
123131
$challenge = MfaChallenge::query()
124-
->where('user_type', get_class($user))
125-
->where('user_id', $user->getAuthIdentifier())
132+
->where($typeColumn, get_class($user))
133+
->where($idColumn, $user->getAuthIdentifier())
126134
->where('method', strtolower($method))
127135
->whereNull('consumed_at')
128136
->where('expires_at', '>', $now)
@@ -170,9 +178,13 @@ public function shouldSkipVerification(Authenticatable $user, ?string $token): b
170178

171179
$hash = hash('sha256', $token);
172180
$now = Carbon::now();
181+
$morph = $this->config['morph'] ?? [];
182+
$morphName = $morph['name'] ?? 'model';
183+
$typeColumn = $morphName . '_type';
184+
$idColumn = $morphName . '_id';
173185
$record = MfaRememberedDevice::query()
174-
->where('user_type', get_class($user))
175-
->where('user_id', $user->getAuthIdentifier())
186+
->where($typeColumn, get_class($user))
187+
->where($idColumn, $user->getAuthIdentifier())
176188
->where('token_hash', $hash)
177189
->where('expires_at', '>', $now)
178190
->first();
@@ -206,8 +218,12 @@ public function rememberDevice(Authenticatable $user, ?int $lifetimeDays = null,
206218
$hash = hash('sha256', $plainToken);
207219

208220
$record = new MfaRememberedDevice();
209-
$record->user_type = get_class($user);
210-
$record->user_id = $user->getAuthIdentifier();
221+
$morph = $this->config['morph'] ?? [];
222+
$morphName = $morph['name'] ?? 'model';
223+
$typeColumn = $morphName . '_type';
224+
$idColumn = $morphName . '_id';
225+
$record->setAttribute($typeColumn, get_class($user));
226+
$record->setAttribute($idColumn, $user->getAuthIdentifier());
211227
$record->token_hash = $hash;
212228
$record->device_name = $deviceName;
213229
$request = app('request');
@@ -243,9 +259,13 @@ public function makeRememberCookie(string $token, ?int $lifetimeDays = null): Co
243259
public function forgetRememberedDevice(Authenticatable $user, string $token): int
244260
{
245261
$hash = hash('sha256', $token);
262+
$morph = $this->config['morph'] ?? [];
263+
$morphName = $morph['name'] ?? 'model';
264+
$typeColumn = $morphName . '_type';
265+
$idColumn = $morphName . '_id';
246266
return MfaRememberedDevice::query()
247-
->where('user_type', get_class($user))
248-
->where('user_id', $user->getAuthIdentifier())
267+
->where($typeColumn, get_class($user))
268+
->where($idColumn, $user->getAuthIdentifier())
249269
->where('token_hash', $hash)
250270
->delete();
251271
}
@@ -255,8 +275,12 @@ public function enableMethod(Authenticatable $user, string $method, array $attri
255275
$record = $this->getMethod($user, $method);
256276
if (! $record) {
257277
$record = new MfaMethod();
258-
$record->user_type = get_class($user);
259-
$record->user_id = $user->getAuthIdentifier();
278+
$morph = $this->config['morph'] ?? [];
279+
$morphName = $morph['name'] ?? 'model';
280+
$typeColumn = $morphName . '_type';
281+
$idColumn = $morphName . '_id';
282+
$record->setAttribute($typeColumn, get_class($user));
283+
$record->setAttribute($idColumn, $user->getAuthIdentifier());
260284
$record->method = strtolower($method);
261285
}
262286

@@ -288,9 +312,13 @@ public function isEnabled(Authenticatable $user, string $method): bool
288312

289313
public function getMethod(Authenticatable $user, string $method): ?MfaMethod
290314
{
315+
$morph = $this->config['morph'] ?? [];
316+
$morphName = $morph['name'] ?? 'model';
317+
$typeColumn = $morphName . '_type';
318+
$idColumn = $morphName . '_id';
291319
return MfaMethod::query()
292-
->where('user_type', get_class($user))
293-
->where('user_id', $user->getAuthIdentifier())
320+
->where($typeColumn, get_class($user))
321+
->where($idColumn, $user->getAuthIdentifier())
294322
->where('method', strtolower($method))
295323
->first();
296324
}
@@ -305,9 +333,13 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null,
305333
$length = $length ?? (int) Arr::get($this->config, 'recovery.code_length', 10);
306334

307335
if ($replaceExisting) {
336+
$morph = $this->config['morph'] ?? [];
337+
$morphName = $morph['name'] ?? 'model';
338+
$typeColumn = $morphName . '_type';
339+
$idColumn = $morphName . '_id';
308340
MfaRecoveryCode::query()
309-
->where('user_type', get_class($user))
310-
->where('user_id', $user->getAuthIdentifier())
341+
->where($typeColumn, get_class($user))
342+
->where($idColumn, $user->getAuthIdentifier())
311343
->delete();
312344
}
313345

@@ -317,8 +349,12 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null,
317349
$hash = $this->hashRecoveryCode($code);
318350

319351
$record = new MfaRecoveryCode();
320-
$record->user_type = get_class($user);
321-
$record->user_id = $user->getAuthIdentifier();
352+
$morph = $this->config['morph'] ?? [];
353+
$morphName = $morph['name'] ?? 'model';
354+
$typeColumn = $morphName . '_type';
355+
$idColumn = $morphName . '_id';
356+
$record->setAttribute($typeColumn, get_class($user));
357+
$record->setAttribute($idColumn, $user->getAuthIdentifier());
322358
$record->code_hash = $hash;
323359
$record->used_at = null;
324360
$record->save();
@@ -333,9 +369,13 @@ public function generateRecoveryCodes(Authenticatable $user, ?int $count = null,
333369
public function verifyRecoveryCode(Authenticatable $user, string $code): bool
334370
{
335371
$hash = $this->hashRecoveryCode($code);
372+
$morph = $this->config['morph'] ?? [];
373+
$morphName = $morph['name'] ?? 'model';
374+
$typeColumn = $morphName . '_type';
375+
$idColumn = $morphName . '_id';
336376
$record = MfaRecoveryCode::query()
337-
->where('user_type', get_class($user))
338-
->where('user_id', $user->getAuthIdentifier())
377+
->where($typeColumn, get_class($user))
378+
->where($idColumn, $user->getAuthIdentifier())
339379
->whereNull('used_at')
340380
->where('code_hash', $hash)
341381
->first();
@@ -359,19 +399,27 @@ public function verifyRecoveryCode(Authenticatable $user, string $code): bool
359399
/** Get remaining (unused) recovery codes count for the user. */
360400
public function getRemainingRecoveryCodesCount(Authenticatable $user): int
361401
{
402+
$morph = $this->config['morph'] ?? [];
403+
$morphName = $morph['name'] ?? 'model';
404+
$typeColumn = $morphName . '_type';
405+
$idColumn = $morphName . '_id';
362406
return MfaRecoveryCode::query()
363-
->where('user_type', get_class($user))
364-
->where('user_id', $user->getAuthIdentifier())
407+
->where($typeColumn, get_class($user))
408+
->where($idColumn, $user->getAuthIdentifier())
365409
->whereNull('used_at')
366410
->count();
367411
}
368412

369413
/** Delete all recovery codes for the user. Returns number deleted. */
370414
public function clearRecoveryCodes(Authenticatable $user): int
371415
{
416+
$morph = $this->config['morph'] ?? [];
417+
$morphName = $morph['name'] ?? 'model';
418+
$typeColumn = $morphName . '_type';
419+
$idColumn = $morphName . '_id';
372420
return MfaRecoveryCode::query()
373-
->where('user_type', get_class($user))
374-
->where('user_id', $user->getAuthIdentifier())
421+
->where($typeColumn, get_class($user))
422+
->where($idColumn, $user->getAuthIdentifier())
375423
->delete();
376424
}
377425

0 commit comments

Comments
 (0)