Skip to content

Commit 71256d7

Browse files
committed
feat: Add SVG QR code generation and fix GD backend issue
- Add generateSvg() and generateBase64Svg() methods to QrCodeGenerator - Add generateTotpQrCodeSvg() and generateTotpQrCodeBase64Svg() methods to MFA class - Fix QrCodeGenerator to use correct bacon-qr-code API: - Use GDLibRenderer for GD extension instead of non-existent GdImageBackEnd - Use ImageRenderer with ImagickImageBackEnd for Imagick - Add comprehensive test coverage for SVG QR code generation - Update facade annotations with new SVG methods - Maintain backward compatibility with existing PNG generation The bacon-qr-code library has two rendering approaches: 1. ImageRenderer with ImageBackEndInterface implementations (SVG, Imagick) 2. GDLibRenderer for direct GD usage This fixes the issue where GdImageBackEnd class doesn't exist in the library.
1 parent bb87f30 commit 71256d7

File tree

4 files changed

+211
-8
lines changed

4 files changed

+211
-8
lines changed

src/Facades/MFA.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212
*
1313
* @method static array setupTotp(\Illuminate\Contracts\Auth\Authenticatable $user, ?string $issuer = null, ?string $label = null)
1414
* @method static bool verifyTotp(\Illuminate\Contracts\Auth\Authenticatable $user, string $code)
15-
* @method static ?\CodingLibs\MFA\Models\MfaChallenge issueChallenge(\Illuminate\Contracts\Auth\Authenticatable $user, string $method)
15+
* @method static ?\CodingLibs\MFA\Models\MfaChallenge issueChallenge(\Illuminate\Contracts\Auth\Authenticatable $user, string $method, bool $send = true)
16+
* @method static ?\CodingLibs\MFA\Models\MfaChallenge generateChallenge(\Illuminate\Contracts\Auth\Authenticatable $user, string $method)
1617
* @method static void registerChannel(\CodingLibs\MFA\Contracts\MfaChannel $channel)
18+
* @method static void registerChannelFromConfig(string $type, array $config)
19+
* @method static ?\CodingLibs\MFA\Contracts\MfaChannel getChannel(string $name)
1720
* @method static ?string generateTotpQrCodeBase64(\Illuminate\Contracts\Auth\Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200)
21+
* @method static ?string generateTotpQrCodeSvg(\Illuminate\Contracts\Auth\Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200)
22+
* @method static ?string generateTotpQrCodeBase64Svg(\Illuminate\Contracts\Auth\Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200)
1823
* @method static bool verifyChallenge(\Illuminate\Contracts\Auth\Authenticatable $user, string $method, string $code)
1924
* @method static bool isRememberEnabled()
2025
* @method static string getRememberCookieName()

src/MFA.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ public function generateTotpQrCodeBase64(Authenticatable $user, ?string $issuer
188188
return QrCodeGenerator::generateBase64Png($otpauth, $size);
189189
}
190190

191+
public function generateTotpQrCodeSvg(Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200): ?string
192+
{
193+
$method = $this->getMethod($user, 'totp');
194+
if (! $method || ! $method->secret) {
195+
return null;
196+
}
197+
198+
$issuer = $issuer ?: Arr::get($this->config, 'totp.issuer', 'Laravel');
199+
$label = $label ?: (method_exists($user, 'getEmailForVerification') ? $user->getEmailForVerification() : ($user->email ?? (string) $user->getAuthIdentifier()));
200+
$digits = Arr::get($this->config, 'totp.digits', 6);
201+
$period = Arr::get($this->config, 'totp.period', 30);
202+
203+
$otpauth = GoogleTotp::buildOtpAuthUrl($method->secret, $label, $issuer, $digits, $period);
204+
return QrCodeGenerator::generateSvg($otpauth, $size);
205+
}
206+
207+
public function generateTotpQrCodeBase64Svg(Authenticatable $user, ?string $issuer = null, ?string $label = null, int $size = 200): ?string
208+
{
209+
$method = $this->getMethod($user, 'totp');
210+
if (! $method || ! $method->secret) {
211+
return null;
212+
}
213+
214+
$issuer = $issuer ?: Arr::get($this->config, 'totp.issuer', 'Laravel');
215+
$label = $label ?: (method_exists($user, 'getEmailForVerification') ? $user->getEmailForVerification() : ($user->email ?? (string) $user->getAuthIdentifier()));
216+
$digits = Arr::get($this->config, 'totp.digits', 6);
217+
$period = Arr::get($this->config, 'totp.period', 30);
218+
219+
$otpauth = GoogleTotp::buildOtpAuthUrl($method->secret, $label, $issuer, $digits, $period);
220+
return QrCodeGenerator::generateBase64Svg($otpauth, $size);
221+
}
222+
191223
public function verifyChallenge(Authenticatable $user, string $method, string $code): bool
192224
{
193225
$now = Carbon::now();

src/Support/QrCodeGenerator.php

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
use BaconQrCode\Renderer\Color\Rgb;
66
use BaconQrCode\Renderer\ImageRenderer;
7-
use BaconQrCode\Renderer\Image\GdImageBackEnd;
7+
use BaconQrCode\Renderer\GDLibRenderer;
88
use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
9+
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
910
use BaconQrCode\Renderer\Module\SquareModule;
1011
use BaconQrCode\Renderer\RendererStyle\Fill;
1112
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
@@ -14,6 +15,14 @@
1415
class QrCodeGenerator
1516
{
1617
public static function generateBase64Png(string $text, int $size = 200): string
18+
{
19+
$renderer = self::selectRenderer($size);
20+
$writer = new Writer($renderer);
21+
$pngData = $writer->writeString($text);
22+
return 'data:image/png;base64,' . base64_encode($pngData);
23+
}
24+
25+
public static function generateSvg(string $text, int $size = 200): string
1726
{
1827
$renderer = new ImageRenderer(
1928
new RendererStyle(
@@ -24,22 +33,43 @@ public static function generateBase64Png(string $text, int $size = 200): string
2433
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0)),
2534
SquareModule::instance()
2635
),
27-
self::selectImageBackEnd()
36+
new SvgImageBackEnd()
2837
);
2938

3039
$writer = new Writer($renderer);
31-
$pngData = $writer->writeString($text);
32-
return 'data:image/png;base64,' . base64_encode($pngData);
40+
return $writer->writeString($text);
41+
}
42+
43+
public static function generateBase64Svg(string $text, int $size = 200): string
44+
{
45+
$svgData = self::generateSvg($text, $size);
46+
return 'data:image/svg+xml;base64,' . base64_encode($svgData);
3347
}
3448

35-
private static function selectImageBackEnd()
49+
private static function selectRenderer(int $size)
3650
{
3751
if (class_exists(\Imagick::class)) {
38-
return new ImagickImageBackEnd();
52+
return new ImageRenderer(
53+
new RendererStyle(
54+
$size,
55+
0,
56+
null,
57+
null,
58+
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0)),
59+
SquareModule::instance()
60+
),
61+
new ImagickImageBackEnd()
62+
);
3963
}
4064

4165
if (extension_loaded('gd')) {
42-
return new GdImageBackEnd();
66+
return new GDLibRenderer(
67+
$size,
68+
4, // margin
69+
'png', // image format
70+
9, // compression quality
71+
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))
72+
);
4373
}
4474

4575
throw new \RuntimeException('No image backend available: install Imagick or enable GD.');

tests/Feature/SvgQrCodeTest.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
use CodingLibs\MFA\MFA;
4+
use CodingLibs\MFA\Support\QrCodeGenerator;
5+
use Illuminate\Contracts\Auth\Authenticatable;
6+
7+
class SvgQrCodeTestFakeUser implements Authenticatable
8+
{
9+
public function __construct(public int|string $id, public ?string $email = null) {}
10+
public function getAuthIdentifierName() { return 'id'; }
11+
public function getAuthIdentifier() { return $this->id; }
12+
public function getAuthPassword() { return ''; }
13+
public function getAuthPasswordName() { return 'password'; }
14+
public function getRememberToken() { return null; }
15+
public function setRememberToken($value): void {}
16+
public function getRememberTokenName() { return 'remember_token'; }
17+
public function getEmailForVerification() { return $this->email; }
18+
}
19+
20+
it('generates SVG QR code for TOTP', function () {
21+
$user = new SvgQrCodeTestFakeUser(4001, 'user@example.com');
22+
$mfa = app(MFA::class);
23+
24+
$result = $mfa->setupTotp($user, 'TestApp', 'user@example.com');
25+
expect($result)->toHaveKeys(['secret', 'otpauth_url']);
26+
27+
$svg = $mfa->generateTotpQrCodeSvg($user, 'TestApp', 'user@example.com', 200);
28+
29+
expect($svg)->toBeString();
30+
expect($svg)->toContain('<svg');
31+
expect($svg)->toContain('xmlns="http://www.w3.org/2000/svg"');
32+
expect($svg)->toContain('width="200"');
33+
expect($svg)->toContain('height="200"');
34+
expect($svg)->toContain('</svg>');
35+
});
36+
37+
it('generates base64 encoded SVG QR code for TOTP', function () {
38+
$user = new SvgQrCodeTestFakeUser(4002, 'user2@example.com');
39+
$mfa = app(MFA::class);
40+
41+
$result = $mfa->setupTotp($user, 'TestApp', 'user2@example.com');
42+
expect($result)->toHaveKeys(['secret', 'otpauth_url']);
43+
44+
$base64Svg = $mfa->generateTotpQrCodeBase64Svg($user, 'TestApp', 'user2@example.com', 200);
45+
46+
expect($base64Svg)->toBeString();
47+
expect($base64Svg)->toStartWith('data:image/svg+xml;base64,');
48+
49+
// Decode and verify it's valid SVG
50+
$decodedSvg = base64_decode(substr($base64Svg, 26)); // Remove data:image/svg+xml;base64,
51+
expect($decodedSvg)->toContain('<svg');
52+
expect($decodedSvg)->toContain('</svg>');
53+
});
54+
55+
it('returns null for SVG generation when TOTP not set up', function () {
56+
$user = new SvgQrCodeTestFakeUser(4003, 'user3@example.com');
57+
$mfa = app(MFA::class);
58+
59+
$svg = $mfa->generateTotpQrCodeSvg($user);
60+
expect($svg)->toBeNull();
61+
62+
$base64Svg = $mfa->generateTotpQrCodeBase64Svg($user);
63+
expect($base64Svg)->toBeNull();
64+
});
65+
66+
it('generates SVG with custom size', function () {
67+
$user = new SvgQrCodeTestFakeUser(4004, 'user4@example.com');
68+
$mfa = app(MFA::class);
69+
70+
$result = $mfa->setupTotp($user, 'TestApp', 'user4@example.com');
71+
expect($result)->toHaveKeys(['secret', 'otpauth_url']);
72+
73+
$svg = $mfa->generateTotpQrCodeSvg($user, 'TestApp', 'user4@example.com', 300);
74+
75+
expect($svg)->toBeString();
76+
expect($svg)->toContain('width="300"');
77+
expect($svg)->toContain('height="300"');
78+
});
79+
80+
it('QrCodeGenerator generates raw SVG', function () {
81+
$testText = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=TestApp';
82+
83+
$svg = QrCodeGenerator::generateSvg($testText, 200);
84+
85+
expect($svg)->toBeString();
86+
expect($svg)->toContain('<svg');
87+
expect($svg)->toContain('xmlns="http://www.w3.org/2000/svg"');
88+
expect($svg)->toContain('width="200"');
89+
expect($svg)->toContain('height="200"');
90+
expect($svg)->toContain('</svg>');
91+
});
92+
93+
it('QrCodeGenerator generates base64 encoded SVG', function () {
94+
$testText = 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=TestApp';
95+
96+
$base64Svg = QrCodeGenerator::generateBase64Svg($testText, 200);
97+
98+
expect($base64Svg)->toBeString();
99+
expect($base64Svg)->toStartWith('data:image/svg+xml;base64,');
100+
101+
// Decode and verify it's valid SVG
102+
$decodedSvg = base64_decode(substr($base64Svg, 26));
103+
expect($decodedSvg)->toContain('<svg');
104+
expect($decodedSvg)->toContain('</svg>');
105+
});
106+
107+
it('SVG QR codes are scalable and contain proper viewBox', function () {
108+
$user = new SvgQrCodeTestFakeUser(4005, 'user5@example.com');
109+
$mfa = app(MFA::class);
110+
111+
$result = $mfa->setupTotp($user, 'TestApp', 'user5@example.com');
112+
expect($result)->toHaveKeys(['secret', 'otpauth_url']);
113+
114+
$svg = $mfa->generateTotpQrCodeSvg($user, 'TestApp', 'user5@example.com', 200);
115+
116+
expect($svg)->toContain('viewBox="0 0 200 200"');
117+
118+
// Test with different size
119+
$svg300 = $mfa->generateTotpQrCodeSvg($user, 'TestApp', 'user5@example.com', 300);
120+
expect($svg300)->toContain('viewBox="0 0 300 300"');
121+
});
122+
123+
it('SVG QR codes work in HTML img tags', function () {
124+
$user = new SvgQrCodeTestFakeUser(4006, 'user6@example.com');
125+
$mfa = app(MFA::class);
126+
127+
$result = $mfa->setupTotp($user, 'TestApp', 'user6@example.com');
128+
expect($result)->toHaveKeys(['secret', 'otpauth_url']);
129+
130+
$base64Svg = $mfa->generateTotpQrCodeBase64Svg($user, 'TestApp', 'user6@example.com', 200);
131+
132+
// This should work in an HTML img tag
133+
$html = '<img src="' . $base64Svg . '" alt="QR Code" />';
134+
expect($html)->toContain('data:image/svg+xml;base64,');
135+
expect($html)->toContain('<img');
136+
});

0 commit comments

Comments
 (0)