Skip to content

Commit 32c08ef

Browse files
committed
feat: Add configurable channel classes and challenge generation without sending
- Add 'channel' config option for email and sms channels to allow custom implementations - Add createChannel() method with validation for custom channel classes - Add registerChannelFromConfig() method for programmatic channel registration - Add getChannel() method to retrieve channels from registry - Add generateChallenge() method to create challenge codes without sending - Add optional parameter to issueChallenge() for backward compatibility - Add comprehensive test coverage for all new functionality - Maintain full backward compatibility with existing code Breaking changes: None - all changes are additive and backward compatible
1 parent b484ffe commit 32c08ef

File tree

4 files changed

+430
-23
lines changed

4 files changed

+430
-23
lines changed

config/mfa.php

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
'from_address' => env('MFA_EMAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')),
1010
'from_name' => env('MFA_EMAIL_FROM_NAME', env('MAIL_FROM_NAME', 'Laravel')),
1111
'subject' => env('MFA_EMAIL_SUBJECT', 'Your verification code'),
12+
'channel' => env('MFA_EMAIL_CHANNEL', \CodingLibs\MFA\Channels\EmailChannel::class),
1213
],
1314

1415
'sms' => [
1516
'enabled' => true,
1617
'driver' => env('MFA_SMS_DRIVER', 'log'), // log|null
1718
'from' => env('MFA_SMS_FROM', ''),
19+
'channel' => env('MFA_SMS_CHANNEL', \CodingLibs\MFA\Channels\SmsChannel::class),
1820
],
1921

2022
'totp' => [
@@ -25,38 +27,38 @@
2527
],
2628

2729
'remember' => [
28-
'enabled' => env('MFA_REMEMBER_ENABLED', true),
29-
'cookie' => env('MFA_REMEMBER_COOKIE', 'mfa_rd'),
30-
'lifetime_days' => (int) env('MFA_REMEMBER_LIFETIME_DAYS', 30),
31-
'path' => env('MFA_REMEMBER_PATH', '/'),
32-
'domain' => env('MFA_REMEMBER_DOMAIN', null),
33-
'secure' => env('MFA_REMEMBER_SECURE', null), // null to follow app('request')->isSecure()
34-
'http_only' => env('MFA_REMEMBER_HTTP_ONLY', true),
35-
'same_site' => env('MFA_REMEMBER_SAME_SITE', 'lax'), // lax|strict|none
30+
'enabled' => env('MFA_REMEMBER_ENABLED', true),
31+
'cookie' => env('MFA_REMEMBER_COOKIE', 'mfa_rd'),
32+
'lifetime_days' => (int)env('MFA_REMEMBER_LIFETIME_DAYS', 30),
33+
'path' => env('MFA_REMEMBER_PATH', '/'),
34+
'domain' => env('MFA_REMEMBER_DOMAIN', null),
35+
'secure' => env('MFA_REMEMBER_SECURE', null), // null to follow app('request')->isSecure()
36+
'http_only' => env('MFA_REMEMBER_HTTP_ONLY', true),
37+
'same_site' => env('MFA_REMEMBER_SAME_SITE', 'lax'), // lax|strict|none
3638
],
3739

3840
'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'),
41+
'enabled' => env('MFA_RECOVERY_ENABLED', true),
42+
'codes_count' => (int)env('MFA_RECOVERY_CODES_COUNT', 10),
43+
'code_length' => (int)env('MFA_RECOVERY_CODE_LENGTH', 10),
44+
'regenerate_on_use' => env('MFA_RECOVERY_REGENERATE_ON_USE', false),
45+
'hash_algo' => env('MFA_RECOVERY_HASH_ALGO', 'sha256'),
4446
],
4547

4648
// Polymorphic owner of MFA records: columns will be model_type/model_id
47-
'morph' => [
49+
'morph' => [
4850
// Column name prefix; results in `${name}_type` and `${name}_id`
49-
'name' => env('MFA_MORPH_NAME', 'model'),
51+
'name' => env('MFA_MORPH_NAME', 'model'),
5052

5153
// ID column type for `${name}_id`.
5254
// Supported: unsignedBigInteger (default) | unsignedInteger | bigInteger | integer | string | uuid | ulid
53-
'type' => env('MFA_MORPH_TYPE', 'unsignedBigInteger'),
55+
'type' => env('MFA_MORPH_TYPE', 'unsignedBigInteger'),
5456

5557
// Length for `${name}_id` when type is "string"
56-
'string_length' => (int) env('MFA_MORPH_STRING_LENGTH', 40),
58+
'string_length' => (int)env('MFA_MORPH_STRING_LENGTH', 40),
5759

5860
// Length for `${name}_type` column
59-
'type_length' => (int) env('MFA_MORPH_TYPE_LENGTH', 255),
61+
'type_length' => (int)env('MFA_MORPH_TYPE_LENGTH', 255),
6062
],
6163
];
6264

src/MFA.php

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,32 @@ public function __construct(array $config)
3232

3333
protected function registerDefaultChannels(): void
3434
{
35-
$this->registry->register(new EmailChannel($this->config['email'] ?? []));
36-
$this->registry->register(new SmsChannel($this->config['sms'] ?? []));
35+
$this->registry->register($this->createChannel('email', $this->config['email'] ?? []));
36+
$this->registry->register($this->createChannel('sms', $this->config['sms'] ?? []));
37+
}
38+
39+
protected function createChannel(string $type, array $config): MfaChannel
40+
{
41+
$channelClass = $config['channel'] ?? $this->getDefaultChannelClass($type);
42+
43+
if (!class_exists($channelClass)) {
44+
throw new \InvalidArgumentException("Channel class '{$channelClass}' does not exist for type '{$type}'");
45+
}
46+
47+
if (!is_subclass_of($channelClass, MfaChannel::class)) {
48+
throw new \InvalidArgumentException("Channel class '{$channelClass}' must implement " . MfaChannel::class);
49+
}
50+
51+
return new $channelClass($config);
52+
}
53+
54+
protected function getDefaultChannelClass(string $type): string
55+
{
56+
return match ($type) {
57+
'email' => EmailChannel::class,
58+
'sms' => SmsChannel::class,
59+
default => throw new \InvalidArgumentException("Unknown channel type: {$type}")
60+
};
3761
}
3862

3963
public function setupTotp(Authenticatable $user, ?string $issuer = null, ?string $label = null): array
@@ -70,7 +94,27 @@ public function verifyTotp(Authenticatable $user, string $code): bool
7094
return $verified;
7195
}
7296

73-
public function issueChallenge(Authenticatable $user, string $method): ?MfaChallenge
97+
public function issueChallenge(Authenticatable $user, string $method, bool $send = true): ?MfaChallenge
98+
{
99+
$method = strtolower($method);
100+
$channel = $this->registry->get($method);
101+
if (! $channel) {
102+
return null;
103+
}
104+
105+
$challenge = $this->generateChallenge($user, $method);
106+
if (! $challenge) {
107+
return null;
108+
}
109+
110+
if ($send) {
111+
$channel->send($user, $challenge->code);
112+
}
113+
114+
return $challenge;
115+
}
116+
117+
public function generateChallenge(Authenticatable $user, string $method): ?MfaChallenge
74118
{
75119
$method = strtolower($method);
76120
$channel = $this->registry->get($method);
@@ -94,8 +138,6 @@ public function issueChallenge(Authenticatable $user, string $method): ?MfaChall
94138
$challenge->expires_at = Carbon::now()->addSeconds($ttlSeconds);
95139
$challenge->save();
96140

97-
$channel->send($user, $code);
98-
99141
return $challenge;
100142
}
101143

@@ -104,6 +146,32 @@ public function registerChannel(MfaChannel $channel): void
104146
$this->registry->register($channel);
105147
}
106148

149+
public function registerChannelFromConfig(string $type, array $config): void
150+
{
151+
$channel = $this->createChannelFromConfig($config);
152+
$this->registry->register($channel);
153+
}
154+
155+
protected function createChannelFromConfig(array $config): MfaChannel
156+
{
157+
$channelClass = $config['channel'] ?? throw new \InvalidArgumentException('channel must be specified in config');
158+
159+
if (!class_exists($channelClass)) {
160+
throw new \InvalidArgumentException("Channel class '{$channelClass}' does not exist");
161+
}
162+
163+
if (!is_subclass_of($channelClass, MfaChannel::class)) {
164+
throw new \InvalidArgumentException("Channel class '{$channelClass}' must implement " . MfaChannel::class);
165+
}
166+
167+
return new $channelClass($config);
168+
}
169+
170+
public function getChannel(string $name): ?MfaChannel
171+
{
172+
return $this->registry->get($name);
173+
}
174+
107175
public function generateTotpQrCodeBase64(Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200): ?string
108176
{
109177
$method = $this->getMethod($user, 'totp');
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
use CodingLibs\MFA\MFA;
4+
use CodingLibs\MFA\Contracts\MfaChannel;
5+
use CodingLibs\MFA\Models\MfaChallenge;
6+
use Illuminate\Contracts\Auth\Authenticatable;
7+
8+
class ChallengeGenerationTestFakeUser implements Authenticatable
9+
{
10+
public function __construct(public int|string $id, public ?string $email = null) {}
11+
public function getAuthIdentifierName() { return 'id'; }
12+
public function getAuthIdentifier() { return $this->id; }
13+
public function getAuthPassword() { return ''; }
14+
public function getAuthPasswordName() { return 'password'; }
15+
public function getRememberToken() { return null; }
16+
public function setRememberToken($value): void {}
17+
public function getRememberTokenName() { return 'remember_token'; }
18+
public function getEmailForVerification() { return $this->email; }
19+
}
20+
21+
class ChallengeGenerationTestChannel implements MfaChannel
22+
{
23+
public ?string $lastCode = null;
24+
public int $sendCallCount = 0;
25+
26+
public function __construct(protected array $config = []) {}
27+
28+
public function getName(): string { return 'test_channel'; }
29+
30+
public function send(Authenticatable $user, string $code, array $options = []): void
31+
{
32+
$this->lastCode = $code;
33+
$this->sendCallCount++;
34+
}
35+
}
36+
37+
it('generates challenge without sending when send=false', function () {
38+
$user = new ChallengeGenerationTestFakeUser(3001, 'user@example.com');
39+
$mfa = app(MFA::class);
40+
41+
$channel = new ChallengeGenerationTestChannel();
42+
$mfa->registerChannel($channel);
43+
44+
$challenge = $mfa->issueChallenge($user, 'test_channel', false);
45+
46+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
47+
expect($challenge->code)->toBeString()->toHaveLength(6);
48+
expect($challenge->method)->toBe('test_channel');
49+
expect($challenge->expires_at)->not->toBeNull();
50+
51+
// Channel should not have been called
52+
expect($channel->sendCallCount)->toBe(0);
53+
expect($channel->lastCode)->toBeNull();
54+
});
55+
56+
it('generates challenge and sends when send=true (default behavior)', function () {
57+
$user = new ChallengeGenerationTestFakeUser(3002, 'user@example.com');
58+
$mfa = app(MFA::class);
59+
60+
$channel = new ChallengeGenerationTestChannel();
61+
$mfa->registerChannel($channel);
62+
63+
$challenge = $mfa->issueChallenge($user, 'test_channel', true);
64+
65+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
66+
expect($challenge->code)->toBeString()->toHaveLength(6);
67+
68+
// Channel should have been called
69+
expect($channel->sendCallCount)->toBe(1);
70+
expect($channel->lastCode)->toBe($challenge->code);
71+
});
72+
73+
it('generates challenge and sends by default (backward compatibility)', function () {
74+
$user = new ChallengeGenerationTestFakeUser(3003, 'user@example.com');
75+
$mfa = app(MFA::class);
76+
77+
$channel = new ChallengeGenerationTestChannel();
78+
$mfa->registerChannel($channel);
79+
80+
$challenge = $mfa->issueChallenge($user, 'test_channel');
81+
82+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
83+
expect($challenge->code)->toBeString()->toHaveLength(6);
84+
85+
// Channel should have been called (default behavior)
86+
expect($channel->sendCallCount)->toBe(1);
87+
expect($channel->lastCode)->toBe($challenge->code);
88+
});
89+
90+
it('generateChallenge creates challenge without sending', function () {
91+
$user = new ChallengeGenerationTestFakeUser(3004, 'user@example.com');
92+
$mfa = app(MFA::class);
93+
94+
$channel = new ChallengeGenerationTestChannel();
95+
$mfa->registerChannel($channel);
96+
97+
$challenge = $mfa->generateChallenge($user, 'test_channel');
98+
99+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
100+
expect($challenge->code)->toBeString()->toHaveLength(6);
101+
expect($challenge->method)->toBe('test_channel');
102+
expect($challenge->expires_at)->not->toBeNull();
103+
104+
// Channel should never be called with generateChallenge
105+
expect($channel->sendCallCount)->toBe(0);
106+
expect($channel->lastCode)->toBeNull();
107+
});
108+
109+
it('generateChallenge returns null for unknown channel', function () {
110+
$user = new ChallengeGenerationTestFakeUser(3005, 'user@example.com');
111+
$mfa = app(MFA::class);
112+
113+
$challenge = $mfa->generateChallenge($user, 'unknown_channel');
114+
115+
expect($challenge)->toBeNull();
116+
});
117+
118+
it('issueChallenge with send=false returns null for unknown channel', function () {
119+
$user = new ChallengeGenerationTestFakeUser(3006, 'user@example.com');
120+
$mfa = app(MFA::class);
121+
122+
$challenge = $mfa->issueChallenge($user, 'unknown_channel', false);
123+
124+
expect($challenge)->toBeNull();
125+
});
126+
127+
it('can manually send code after generating challenge', function () {
128+
$user = new ChallengeGenerationTestFakeUser(3007, 'user@example.com');
129+
$mfa = app(MFA::class);
130+
131+
$channel = new ChallengeGenerationTestChannel();
132+
$mfa->registerChannel($channel);
133+
134+
// Generate challenge without sending
135+
$challenge = $mfa->generateChallenge($user, 'test_channel');
136+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
137+
expect($channel->sendCallCount)->toBe(0);
138+
139+
// Manually send the code
140+
$channel->send($user, $challenge->code);
141+
expect($channel->sendCallCount)->toBe(1);
142+
expect($channel->lastCode)->toBe($challenge->code);
143+
});
144+
145+
it('generated challenge can be verified normally', function () {
146+
$user = new ChallengeGenerationTestFakeUser(3008, 'user@example.com');
147+
$mfa = app(MFA::class);
148+
149+
$channel = new ChallengeGenerationTestChannel();
150+
$mfa->registerChannel($channel);
151+
152+
// Generate challenge without sending
153+
$challenge = $mfa->generateChallenge($user, 'test_channel');
154+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
155+
156+
// Verify the challenge works normally
157+
$verified = $mfa->verifyChallenge($user, 'test_channel', $challenge->code);
158+
expect($verified)->toBeTrue();
159+
160+
// Check that the challenge was consumed
161+
$challenge->refresh();
162+
expect($challenge->consumed_at)->not->toBeNull();
163+
expect($mfa->isEnabled($user, 'test_channel'))->toBeTrue();
164+
});

0 commit comments

Comments
 (0)