Skip to content

Commit 9250467

Browse files
cursoragentanwarx4u
andcommitted
Create Laravel MFA package with email, SMS, and TOTP support
Co-authored-by: anwarx4u <anwarx4u@gmail.com>
1 parent a317ed0 commit 9250467

File tree

12 files changed

+536
-0
lines changed

12 files changed

+536
-0
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,30 @@
11
# mfa
22
Multi Factor Authentication
3+
CodingLibs Laravel MFA
4+
5+
Installation
6+
- Require in your Laravel 12 app composer.json or via path repository.
7+
- The service provider auto-registers. Publish config and migrations:
8+
```
9+
php artisan vendor:publish --tag=mfa-config
10+
php artisan vendor:publish --tag=mfa-migrations
11+
php artisan migrate
12+
```
13+
14+
Usage
15+
```php
16+
use CodingLibs\MFA\Facades\MFA;
17+
18+
// Email/SMS
19+
$challenge = MFA::issueChallenge(auth()->user(), 'email');
20+
// then later
21+
$ok = MFA::verifyChallenge(auth()->user(), 'email', '123456');
22+
23+
// TOTP
24+
$setup = MFA::setupTotp(auth()->user());
25+
// $setup['otpauth_url'] -> QR code; then verify
26+
$ok = MFA::verifyTotp(auth()->user(), '123456');
27+
```
28+
29+
Configuration
30+
- See `config/mfa.php` for email/sms/totp options.

composer.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "codinglibs/laravel-mfa",
3+
"description": "Laravel 12 Multi-Factor Authentication package (Email, SMS, Google Authenticator TOTP)",
4+
"type": "library",
5+
"license": "MIT",
6+
"authors": [
7+
{
8+
"name": "CodingLibs",
9+
"email": "support@codinglibs.example"
10+
}
11+
],
12+
"require": {
13+
"php": ">=8.2",
14+
"illuminate/support": "^12.0",
15+
"illuminate/database": "^12.0",
16+
"illuminate/mail": "^12.0",
17+
"illuminate/config": "^12.0",
18+
"illuminate/console": "^12.0"
19+
},
20+
"autoload": {
21+
"psr-4": {
22+
"CodingLibs\\MFA\\": "src/"
23+
}
24+
},
25+
"autoload-dev": {
26+
"psr-4": {
27+
"CodingLibs\\MFA\\Tests\\": "tests/"
28+
}
29+
},
30+
"extra": {
31+
"laravel": {
32+
"providers": [
33+
"CodingLibs\\MFA\\MFAServiceProvider"
34+
],
35+
"aliases": {
36+
"MFA": "CodingLibs\\MFA\\Facades\\MFA"
37+
}
38+
}
39+
},
40+
"minimum-stability": "stable",
41+
"prefer-stable": true
42+
}
43+

config/mfa.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
return [
4+
'code_length' => 6,
5+
'code_ttl_seconds' => 300,
6+
7+
'email' => [
8+
'enabled' => true,
9+
'from_address' => env('MFA_EMAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')),
10+
'from_name' => env('MFA_EMAIL_FROM_NAME', env('MAIL_FROM_NAME', 'Laravel')),
11+
'subject' => env('MFA_EMAIL_SUBJECT', 'Your verification code'),
12+
],
13+
14+
'sms' => [
15+
'enabled' => true,
16+
'driver' => env('MFA_SMS_DRIVER', 'log'), // log|null
17+
'from' => env('MFA_SMS_FROM', ''),
18+
],
19+
20+
'totp' => [
21+
'issuer' => env('MFA_TOTP_ISSUER', config('app.name')),
22+
'digits' => (int) env('MFA_TOTP_DIGITS', 6),
23+
'period' => (int) env('MFA_TOTP_PERIOD', 30),
24+
'window' => (int) env('MFA_TOTP_WINDOW', 1),
25+
],
26+
];
27+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('mfa_methods', function (Blueprint $table) {
11+
$table->id();
12+
$table->string('user_type');
13+
$table->string('user_id');
14+
$table->string('method'); // email|sms|totp
15+
$table->text('secret')->nullable(); // for totp
16+
$table->timestamp('enabled_at')->nullable();
17+
$table->timestamp('last_used_at')->nullable();
18+
$table->timestamps();
19+
$table->index(['user_type', 'user_id', 'method']);
20+
});
21+
22+
Schema::create('mfa_challenges', function (Blueprint $table) {
23+
$table->id();
24+
$table->string('user_type');
25+
$table->string('user_id');
26+
$table->string('method'); // email|sms
27+
$table->string('code');
28+
$table->timestamp('expires_at');
29+
$table->timestamp('consumed_at')->nullable();
30+
$table->timestamps();
31+
$table->index(['user_type', 'user_id', 'method']);
32+
});
33+
}
34+
35+
public function down(): void
36+
{
37+
Schema::dropIfExists('mfa_challenges');
38+
Schema::dropIfExists('mfa_methods');
39+
}
40+
};
41+

src/Channels/EmailChannel.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Channels;
4+
5+
use Illuminate\Contracts\Auth\Authenticatable;
6+
use Illuminate\Support\Facades\Mail;
7+
8+
class EmailChannel
9+
{
10+
public function __construct(protected array $config = [])
11+
{
12+
}
13+
14+
public function send(Authenticatable $user, string $code): void
15+
{
16+
if (! ($this->config['enabled'] ?? true)) {
17+
return;
18+
}
19+
20+
$to = method_exists($user, 'getEmailForVerification') ? $user->getEmailForVerification() : ($user->email ?? null);
21+
if (! $to) {
22+
return;
23+
}
24+
25+
$fromAddress = $this->config['from_address'] ?? null;
26+
$fromName = $this->config['from_name'] ?? null;
27+
$subject = $this->config['subject'] ?? 'Your verification code';
28+
29+
Mail::raw("Your verification code is: {$code}", function ($message) use ($to, $fromAddress, $fromName, $subject) {
30+
$message->to($to)->subject($subject);
31+
if ($fromAddress) {
32+
$message->from($fromAddress, $fromName);
33+
}
34+
});
35+
}
36+
}
37+

src/Channels/SmsChannel.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Channels;
4+
5+
use Illuminate\Contracts\Auth\Authenticatable;
6+
use Illuminate\Support\Facades\Log;
7+
8+
class SmsChannel
9+
{
10+
public function __construct(protected array $config = [])
11+
{
12+
}
13+
14+
public function send(Authenticatable $user, string $code): void
15+
{
16+
if (! ($this->config['enabled'] ?? true)) {
17+
return;
18+
}
19+
20+
$driver = $this->config['driver'] ?? 'log';
21+
$to = method_exists($user, 'getPhoneNumberForVerification') ? $user->getPhoneNumberForVerification() : ($user->phone ?? null);
22+
if (! $to) {
23+
return;
24+
}
25+
26+
$message = "Your verification code is: {$code}";
27+
28+
if ($driver === 'log') {
29+
Log::info('MFA SMS', ['to' => $to, 'message' => $message]);
30+
return;
31+
}
32+
33+
// Placeholder: extendable for twilio/aws pinoint/etc via events or bindings.
34+
Log::warning('MFA SMS driver not implemented', ['driver' => $driver, 'to' => $to]);
35+
}
36+
}
37+

src/Facades/MFA.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA\Facades;
4+
5+
use Illuminate\Support\Facades\Facade;
6+
7+
class MFA extends Facade
8+
{
9+
protected static function getFacadeAccessor(): string
10+
{
11+
return 'mfa';
12+
}
13+
}
14+

src/MFA.php

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
namespace CodingLibs\MFA;
4+
5+
use CodingLibs\MFA\Channels\EmailChannel;
6+
use CodingLibs\MFA\Channels\SmsChannel;
7+
use CodingLibs\MFA\Models\MfaChallenge;
8+
use CodingLibs\MFA\Models\MfaMethod;
9+
use CodingLibs\MFA\Totp\GoogleTotp;
10+
use Illuminate\Contracts\Auth\Authenticatable;
11+
use Illuminate\Support\Arr;
12+
use Illuminate\Support\Carbon;
13+
use Illuminate\Support\Facades\DB;
14+
15+
class MFA
16+
{
17+
protected array $config;
18+
19+
public function __construct(array $config)
20+
{
21+
$this->config = $config;
22+
}
23+
24+
public function setupTotp(Authenticatable $user, ?string $issuer = null, ?string $label = null): array
25+
{
26+
$secret = GoogleTotp::generateSecret();
27+
$issuer = $issuer ?: Arr::get($this->config, 'totp.issuer', 'Laravel');
28+
$label = $label ?: method_exists($user, 'getEmailForVerification') ? $user->getEmailForVerification() : ($user->email ?? (string) $user->getAuthIdentifier());
29+
$otpauth = GoogleTotp::buildOtpAuthUrl($secret, $label, $issuer, Arr::get($this->config, 'totp.digits', 6));
30+
31+
$this->enableMethod($user, 'totp', ['secret' => $secret]);
32+
33+
return [
34+
'secret' => $secret,
35+
'otpauth_url' => $otpauth,
36+
];
37+
}
38+
39+
public function verifyTotp(Authenticatable $user, string $code): bool
40+
{
41+
$method = $this->getMethod($user, 'totp');
42+
if (! $method || ! $method->secret) {
43+
return false;
44+
}
45+
46+
$digits = Arr::get($this->config, 'totp.digits', 6);
47+
$period = Arr::get($this->config, 'totp.period', 30);
48+
$window = Arr::get($this->config, 'totp.window', 1);
49+
50+
$verified = GoogleTotp::verifyCode($method->secret, $code, $digits, $period, $window);
51+
if ($verified) {
52+
$method->last_used_at = Carbon::now();
53+
$method->save();
54+
}
55+
return $verified;
56+
}
57+
58+
public function issueChallenge(Authenticatable $user, string $method): ?MfaChallenge
59+
{
60+
$method = strtolower($method);
61+
if (! in_array($method, ['email', 'sms'], true)) {
62+
return null;
63+
}
64+
65+
$codeLength = Arr::get($this->config, 'code_length', 6);
66+
$ttlSeconds = Arr::get($this->config, 'code_ttl_seconds', 300);
67+
$code = str_pad((string) random_int(0, (10 ** $codeLength) - 1), $codeLength, '0', STR_PAD_LEFT);
68+
69+
$challenge = new MfaChallenge();
70+
$challenge->user_type = get_class($user);
71+
$challenge->user_id = $user->getAuthIdentifier();
72+
$challenge->method = $method;
73+
$challenge->code = $code;
74+
$challenge->expires_at = Carbon::now()->addSeconds($ttlSeconds);
75+
$challenge->save();
76+
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+
}
82+
83+
return $challenge;
84+
}
85+
86+
public function verifyChallenge(Authenticatable $user, string $method, string $code): bool
87+
{
88+
$now = Carbon::now();
89+
90+
$challenge = MfaChallenge::query()
91+
->where('user_type', get_class($user))
92+
->where('user_id', $user->getAuthIdentifier())
93+
->where('method', strtolower($method))
94+
->whereNull('consumed_at')
95+
->where('expires_at', '>', $now)
96+
->latest('id')
97+
->first();
98+
99+
if (! $challenge) {
100+
return false;
101+
}
102+
103+
if (hash_equals($challenge->code, $code)) {
104+
$challenge->consumed_at = $now;
105+
$challenge->save();
106+
107+
$this->enableMethod($user, $challenge->method);
108+
109+
return true;
110+
}
111+
112+
return false;
113+
}
114+
115+
public function enableMethod(Authenticatable $user, string $method, array $attributes = []): MfaMethod
116+
{
117+
$record = $this->getMethod($user, $method);
118+
if (! $record) {
119+
$record = new MfaMethod();
120+
$record->user_type = get_class($user);
121+
$record->user_id = $user->getAuthIdentifier();
122+
$record->method = strtolower($method);
123+
}
124+
125+
foreach ($attributes as $key => $value) {
126+
$record->setAttribute($key, $value);
127+
}
128+
129+
$record->enabled_at = Carbon::now();
130+
$record->save();
131+
132+
return $record;
133+
}
134+
135+
public function disableMethod(Authenticatable $user, string $method): bool
136+
{
137+
$record = $this->getMethod($user, $method);
138+
if (! $record) {
139+
return false;
140+
}
141+
$record->enabled_at = null;
142+
return $record->save();
143+
}
144+
145+
public function isEnabled(Authenticatable $user, string $method): bool
146+
{
147+
$record = $this->getMethod($user, $method);
148+
return (bool) ($record && $record->enabled_at);
149+
}
150+
151+
public function getMethod(Authenticatable $user, string $method): ?MfaMethod
152+
{
153+
return MfaMethod::query()
154+
->where('user_type', get_class($user))
155+
->where('user_id', $user->getAuthIdentifier())
156+
->where('method', strtolower($method))
157+
->first();
158+
}
159+
}
160+

0 commit comments

Comments
 (0)