Skip to content

Commit 80ec6ca

Browse files
cursoragentanwarx4u
andcommitted
Add MFA channel registry and QR code generation support
Co-authored-by: anwarx4u <anwarx4u@gmail.com>
1 parent 9250467 commit 80ec6ca

File tree

7 files changed

+159
-12
lines changed

7 files changed

+159
-12
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,32 @@ $ok = MFA::verifyChallenge(auth()->user(), 'email', '123456');
2424
$setup = MFA::setupTotp(auth()->user());
2525
// $setup['otpauth_url'] -> QR code; then verify
2626
$ok = MFA::verifyTotp(auth()->user(), '123456');
27+
28+
// Generate QR code (base64 PNG) from existing TOTP
29+
$base64 = MFA::generateTotpQrCodeBase64(auth()->user(), issuer: 'MyApp');
30+
// <img src="$base64" />
2731
```
2832

2933
Configuration
30-
- See `config/mfa.php` for email/sms/totp options.
34+
- See `config/mfa.php` for email/sms/totp options.
35+
36+
Extending: Custom Channels
37+
```php
38+
use CodingLibs\MFA\Contracts\MfaChannel;
39+
use CodingLibs\MFA\Facades\MFA;
40+
use Illuminate\Contracts\Auth\Authenticatable;
41+
42+
class WhatsAppChannel implements MfaChannel {
43+
public function __construct(private array $config = []) {}
44+
public function getName(): string { return 'whatsapp'; }
45+
public function send(Authenticatable $user, string $code, array $options = []): void {
46+
// send via provider...
47+
}
48+
}
49+
50+
// register at boot
51+
MFA::registerChannel(new WhatsAppChannel(config('mfa.whatsapp', [])));
52+
53+
// then issue
54+
MFA::issueChallenge(auth()->user(), 'whatsapp');
55+
```

src/ChannelRegistry.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA;
4+
5+
use CodingLibs\MFA\Contracts\MfaChannel;
6+
7+
class ChannelRegistry
8+
{
9+
/** @var array<string, MfaChannel> */
10+
protected array $channels = [];
11+
12+
public function register(MfaChannel $channel): void
13+
{
14+
$this->channels[strtolower($channel->getName())] = $channel;
15+
}
16+
17+
public function has(string $name): bool
18+
{
19+
return array_key_exists(strtolower($name), $this->channels);
20+
}
21+
22+
public function get(string $name): ?MfaChannel
23+
{
24+
return $this->channels[strtolower($name)] ?? null;
25+
}
26+
27+
/** @return array<string, MfaChannel> */
28+
public function all(): array
29+
{
30+
return $this->channels;
31+
}
32+
}
33+

src/Channels/EmailChannel.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22

33
namespace CodingLibs\MFA\Channels;
44

5+
use CodingLibs\MFA\Contracts\MfaChannel;
56
use Illuminate\Contracts\Auth\Authenticatable;
67
use Illuminate\Support\Facades\Mail;
78

8-
class EmailChannel
9+
class EmailChannel implements MfaChannel
910
{
1011
public function __construct(protected array $config = [])
1112
{
1213
}
1314

14-
public function send(Authenticatable $user, string $code): void
15+
public function getName(): string
16+
{
17+
return 'email';
18+
}
19+
20+
public function send(Authenticatable $user, string $code, array $options = []): void
1521
{
1622
if (! ($this->config['enabled'] ?? true)) {
1723
return;
@@ -25,6 +31,9 @@ public function send(Authenticatable $user, string $code): void
2531
$fromAddress = $this->config['from_address'] ?? null;
2632
$fromName = $this->config['from_name'] ?? null;
2733
$subject = $this->config['subject'] ?? 'Your verification code';
34+
if (isset($options['subject'])) {
35+
$subject = $options['subject'];
36+
}
2837

2938
Mail::raw("Your verification code is: {$code}", function ($message) use ($to, $fromAddress, $fromName, $subject) {
3039
$message->to($to)->subject($subject);

src/Channels/SmsChannel.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22

33
namespace CodingLibs\MFA\Channels;
44

5+
use CodingLibs\MFA\Contracts\MfaChannel;
56
use Illuminate\Contracts\Auth\Authenticatable;
67
use Illuminate\Support\Facades\Log;
78

8-
class SmsChannel
9+
class SmsChannel implements MfaChannel
910
{
1011
public function __construct(protected array $config = [])
1112
{
1213
}
1314

14-
public function send(Authenticatable $user, string $code): void
15+
public function getName(): string
16+
{
17+
return 'sms';
18+
}
19+
20+
public function send(Authenticatable $user, string $code, array $options = []): void
1521
{
1622
if (! ($this->config['enabled'] ?? true)) {
1723
return;
@@ -23,7 +29,7 @@ public function send(Authenticatable $user, string $code): void
2329
return;
2430
}
2531

26-
$message = "Your verification code is: {$code}";
32+
$message = $options['message'] ?? "Your verification code is: {$code}";
2733

2834
if ($driver === 'log') {
2935
Log::info('MFA SMS', ['to' => $to, 'message' => $message]);

src/Contracts/MfaChannel.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Contracts;
4+
5+
use Illuminate\Contracts\Auth\Authenticatable;
6+
7+
interface MfaChannel
8+
{
9+
public function getName(): string;
10+
11+
public function send(Authenticatable $user, string $code, array $options = []): void;
12+
}
13+

src/MFA.php

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
use CodingLibs\MFA\Channels\EmailChannel;
66
use CodingLibs\MFA\Channels\SmsChannel;
7+
use CodingLibs\MFA\ChannelRegistry;
8+
use CodingLibs\MFA\Contracts\MfaChannel;
9+
use CodingLibs\MFA\Support\QrCodeGenerator;
710
use CodingLibs\MFA\Models\MfaChallenge;
811
use CodingLibs\MFA\Models\MfaMethod;
912
use CodingLibs\MFA\Totp\GoogleTotp;
@@ -15,10 +18,19 @@
1518
class MFA
1619
{
1720
protected array $config;
21+
protected ChannelRegistry $registry;
1822

1923
public function __construct(array $config)
2024
{
2125
$this->config = $config;
26+
$this->registry = new ChannelRegistry();
27+
$this->registerDefaultChannels();
28+
}
29+
30+
protected function registerDefaultChannels(): void
31+
{
32+
$this->registry->register(new EmailChannel($this->config['email'] ?? []));
33+
$this->registry->register(new SmsChannel($this->config['sms'] ?? []));
2234
}
2335

2436
public function setupTotp(Authenticatable $user, ?string $issuer = null, ?string $label = null): array
@@ -58,7 +70,8 @@ public function verifyTotp(Authenticatable $user, string $code): bool
5870
public function issueChallenge(Authenticatable $user, string $method): ?MfaChallenge
5971
{
6072
$method = strtolower($method);
61-
if (! in_array($method, ['email', 'sms'], true)) {
73+
$channel = $this->registry->get($method);
74+
if (! $channel) {
6275
return null;
6376
}
6477

@@ -74,15 +87,32 @@ public function issueChallenge(Authenticatable $user, string $method): ?MfaChall
7487
$challenge->expires_at = Carbon::now()->addSeconds($ttlSeconds);
7588
$challenge->save();
7689

77-
if ($method === 'email') {
78-
(new EmailChannel($this->config['email'] ?? []))->send($user, $code);
79-
} else if ($method === 'sms') {
80-
(new SmsChannel($this->config['sms'] ?? []))->send($user, $code);
81-
}
90+
$channel->send($user, $code);
8291

8392
return $challenge;
8493
}
8594

95+
public function registerChannel(MfaChannel $channel): void
96+
{
97+
$this->registry->register($channel);
98+
}
99+
100+
public function generateTotpQrCodeBase64(Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200): ?string
101+
{
102+
$method = $this->getMethod($user, 'totp');
103+
if (! $method || ! $method->secret) {
104+
return null;
105+
}
106+
107+
$issuer = $issuer ?: Arr::get($this->config, 'totp.issuer', 'Laravel');
108+
$label = $label ?: (method_exists($user, 'getEmailForVerification') ? $user->getEmailForVerification() : ($user->email ?? (string) $user->getAuthIdentifier()));
109+
$digits = Arr::get($this->config, 'totp.digits', 6);
110+
$period = Arr::get($this->config, 'totp.period', 30);
111+
112+
$otpauth = GoogleTotp::buildOtpAuthUrl($method->secret, $label, $issuer, $digits, $period);
113+
return QrCodeGenerator::generateBase64Png($otpauth, $size);
114+
}
115+
86116
public function verifyChallenge(Authenticatable $user, string $method, string $code): bool
87117
{
88118
$now = Carbon::now();

src/Support/QrCodeGenerator.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Support;
4+
5+
class QrCodeGenerator
6+
{
7+
public static function generateBase64Png(string $text, int $size = 200): string
8+
{
9+
// Lightweight inline QR encoder using endroid/qr-code if available, else fallback to Google Charts
10+
if (class_exists('Endroid\\QrCode\\QrCode')) {
11+
return self::generateWithEndroid($text, $size);
12+
}
13+
return self::generateWithGoogleCharts($text, $size);
14+
}
15+
16+
protected static function generateWithEndroid(string $text, int $size): string
17+
{
18+
$qrCode = new \Endroid\QrCode\QrCode($text);
19+
$qrCode->setSize($size);
20+
$pngData = $qrCode->writeString();
21+
return 'data:image/png;base64,' . base64_encode($pngData);
22+
}
23+
24+
protected static function generateWithGoogleCharts(string $text, int $size): string
25+
{
26+
$url = 'https://chart.googleapis.com/chart?cht=qr&chs=' . $size . 'x' . $size . '&chl=' . rawurlencode($text);
27+
$pngData = @file_get_contents($url) ?: '';
28+
return 'data:image/png;base64,' . base64_encode($pngData);
29+
}
30+
}
31+

0 commit comments

Comments
 (0)