Skip to content

Commit b6a8be2

Browse files
pushpak1300joetannenbaumtaylorotwell
authored
Add Two Factor Auth For Livewire Starter Kit (#113)
* Add Fortify * wip * Support Fortify Options * FIx Tests * Add Tests * Test Fix * Refactor Component * Refactor Code * formatting * Refactoring * Fix test * Formatting * formatting * formatting * refactoring * Refactor input-otp-component it to use the $nextTick * simplify input-otp component * Change conditional signature for better clarity * Refactor input-otp component for improved clarity * Refactor two-factor authentication views for improved clarity and functionality * Add error handling in the components * Refactor confirm password functionality to use fortify confirm password * Use regex instead of checking number * Remove unnecessary calculation of next index in input-otp component * Formatting * use when instead of assigning variable in routes file * formatting * formatting * formatting * undo typo * formatting * formatting * extract x data out to script tag * reset instead of manual values * Revert "extract x data out to script tag" This reverts commit ead85d1. * formatting * Update layout.blade.php * Update input-otp.blade.php * Update two-factor-challenge.blade.php * Update AuthenticationTest.php * formatting * periods * some spacing --------- Co-authored-by: Joe Tannenbaum <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent 4f7aaab commit b6a8be2

20 files changed

+1230
-99
lines changed

app/Models/User.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
use Illuminate\Foundation\Auth\User as Authenticatable;
88
use Illuminate\Notifications\Notifiable;
99
use Illuminate\Support\Str;
10+
use Laravel\Fortify\TwoFactorAuthenticatable;
1011

1112
class User extends Authenticatable
1213
{
1314
/** @use HasFactory<\Database\Factories\UserFactory> */
14-
use HasFactory, Notifiable;
15+
use HasFactory, Notifiable, TwoFactorAuthenticatable;
1516

1617
/**
1718
* The attributes that are mass assignable.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Providers;
4+
5+
use Illuminate\Cache\RateLimiting\Limit;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\RateLimiter;
8+
use Illuminate\Support\ServiceProvider;
9+
use Laravel\Fortify\Fortify;
10+
11+
class FortifyServiceProvider extends ServiceProvider
12+
{
13+
/**
14+
* Register any application services.
15+
*/
16+
public function register(): void
17+
{
18+
//
19+
}
20+
21+
/**
22+
* Bootstrap any application services.
23+
*/
24+
public function boot(): void
25+
{
26+
Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
27+
Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
28+
29+
RateLimiter::for('two-factor', function (Request $request) {
30+
return Limit::perMinute(5)->by($request->session()->get('login.id'));
31+
});
32+
}
33+
}

bootstrap/providers.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
return [
44
App\Providers\AppServiceProvider::class,
5+
App\Providers\FortifyServiceProvider::class,
56
App\Providers\VoltServiceProvider::class,
67
];

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"license": "MIT",
1111
"require": {
1212
"php": "^8.2",
13+
"laravel/fortify": "^1.30",
1314
"laravel/framework": "^12.0",
1415
"laravel/tinker": "^2.10.1",
1516
"livewire/flux": "^2.1.1",

config/fortify.php

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
use Laravel\Fortify\Features;
4+
5+
return [
6+
7+
/*
8+
|--------------------------------------------------------------------------
9+
| Fortify Guard
10+
|--------------------------------------------------------------------------
11+
|
12+
| Here you may specify which authentication guard Fortify will use while
13+
| authenticating users. This value should correspond with one of your
14+
| guards that is already present in your "auth" configuration file.
15+
|
16+
*/
17+
18+
'guard' => 'web',
19+
20+
/*
21+
|--------------------------------------------------------------------------
22+
| Fortify Password Broker
23+
|--------------------------------------------------------------------------
24+
|
25+
| Here you may specify which password broker Fortify can use when a user
26+
| is resetting their password. This configured value should match one
27+
| of your password brokers setup in your "auth" configuration file.
28+
|
29+
*/
30+
31+
'passwords' => 'users',
32+
33+
/*
34+
|--------------------------------------------------------------------------
35+
| Username / Email
36+
|--------------------------------------------------------------------------
37+
|
38+
| This value defines which model attribute should be considered as your
39+
| application's "username" field. Typically, this might be the email
40+
| address of the users but you are free to change this value here.
41+
|
42+
| Out of the box, Fortify expects forgot password and reset password
43+
| requests to have a field named 'email'. If the application uses
44+
| another name for the field you may define it below as needed.
45+
|
46+
*/
47+
48+
'username' => 'email',
49+
50+
'email' => 'email',
51+
52+
/*
53+
|--------------------------------------------------------------------------
54+
| Lowercase Usernames
55+
|--------------------------------------------------------------------------
56+
|
57+
| This value defines whether usernames should be lowercased before saving
58+
| them in the database, as some database system string fields are case
59+
| sensitive. You may disable this for your application if necessary.
60+
|
61+
*/
62+
63+
'lowercase_usernames' => true,
64+
65+
/*
66+
|--------------------------------------------------------------------------
67+
| Home Path
68+
|--------------------------------------------------------------------------
69+
|
70+
| Here you may configure the path where users will get redirected during
71+
| authentication or password reset when the operations are successful
72+
| and the user is authenticated. You are free to change this value.
73+
|
74+
*/
75+
76+
'home' => '/dashboard',
77+
78+
/*
79+
|--------------------------------------------------------------------------
80+
| Fortify Routes Prefix / Subdomain
81+
|--------------------------------------------------------------------------
82+
|
83+
| Here you may specify which prefix Fortify will assign to all the routes
84+
| that it registers with the application. If necessary, you may change
85+
| subdomain under which all of the Fortify routes will be available.
86+
|
87+
*/
88+
89+
'prefix' => '',
90+
91+
'domain' => null,
92+
93+
/*
94+
|--------------------------------------------------------------------------
95+
| Fortify Routes Middleware
96+
|--------------------------------------------------------------------------
97+
|
98+
| Here you may specify which middleware Fortify will assign to the routes
99+
| that it registers with the application. If necessary, you may change
100+
| these middleware but typically this provided default is preferred.
101+
|
102+
*/
103+
104+
'middleware' => ['web'],
105+
106+
/*
107+
|--------------------------------------------------------------------------
108+
| Rate Limiting
109+
|--------------------------------------------------------------------------
110+
|
111+
| By default, Fortify will throttle logins to five requests per minute for
112+
| every email and IP address combination. However, if you would like to
113+
| specify a custom rate limiter to call then you may specify it here.
114+
|
115+
*/
116+
117+
'limiters' => [
118+
'login' => 'login',
119+
'two-factor' => 'two-factor',
120+
],
121+
122+
/*
123+
|--------------------------------------------------------------------------
124+
| Register View Routes
125+
|--------------------------------------------------------------------------
126+
|
127+
| Here you may specify if the routes returning views should be disabled as
128+
| you may not need them when building your own application. This may be
129+
| especially true if you're writing a custom single-page application.
130+
|
131+
*/
132+
133+
'views' => true,
134+
135+
/*
136+
|--------------------------------------------------------------------------
137+
| Features
138+
|--------------------------------------------------------------------------
139+
|
140+
| Some of the Fortify features are optional. You may disable the features
141+
| by removing them from this array. You're free to only remove some of
142+
| these features or you can even remove all of these if you need to.
143+
|
144+
*/
145+
146+
'features' => [
147+
// Features::registration(),
148+
// Features::resetPasswords(),
149+
// Features::emailVerification(),
150+
// Features::updateProfileInformation(),
151+
// Features::updatePasswords(),
152+
Features::twoFactorAuthentication([
153+
'confirm' => true,
154+
'confirmPassword' => true,
155+
// 'window' => 0,
156+
]),
157+
],
158+
159+
];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('users', function (Blueprint $table) {
15+
$table->text('two_factor_secret')->after('password')->nullable();
16+
$table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable();
17+
$table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable();
18+
});
19+
}
20+
21+
/**
22+
* Reverse the migrations.
23+
*/
24+
public function down(): void
25+
{
26+
Schema::table('users', function (Blueprint $table) {
27+
$table->dropColumn([
28+
'two_factor_secret',
29+
'two_factor_recovery_codes',
30+
'two_factor_confirmed_at',
31+
]);
32+
});
33+
}
34+
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
@props([
2+
'digits' => 6,
3+
'name' => 'code',
4+
])
5+
6+
<div
7+
@focus-2fa-auth-code.window="$refs.input1?.focus()"
8+
@clear-2fa-auth-code.window="clearAll()"
9+
class="relative"
10+
x-data="{
11+
totalDigits: @js($digits),
12+
digitIndices: @js(range(1, $digits)),
13+
init() {
14+
$nextTick(() => {
15+
this.$refs.input1?.focus();
16+
});
17+
},
18+
getInput(index) {
19+
return this.$refs['input' + index];
20+
},
21+
setValue(index, value) {
22+
this.getInput(index).value = value;
23+
},
24+
getCode() {
25+
return this.digitIndices
26+
.map(i => this.getInput(i).value)
27+
.join('');
28+
},
29+
updateHiddenField() {
30+
this.$refs.code.value = this.getCode();
31+
this.$refs.code.dispatchEvent(new Event('input', { bubbles: true }));
32+
this.$refs.code.dispatchEvent(new Event('change', { bubbles: true }));
33+
},
34+
handleNumberKey(index, key) {
35+
this.setValue(index, key);
36+
37+
if (index < this.totalDigits) {
38+
this.getInput(index + 1).focus();
39+
}
40+
41+
$nextTick(() => {
42+
this.updateHiddenField();
43+
});
44+
},
45+
handleBackspace(index) {
46+
const currentInput = this.getInput(index);
47+
48+
if (currentInput.value !== '') {
49+
currentInput.value = '';
50+
this.updateHiddenField();
51+
return;
52+
}
53+
54+
if (index <= 1) {
55+
return;
56+
}
57+
58+
const previousInput = this.getInput(index - 1);
59+
60+
previousInput.value = '';
61+
previousInput.focus();
62+
63+
this.updateHiddenField();
64+
},
65+
handleKeyDown(index, event) {
66+
const key = event.key;
67+
68+
if (/^[0-9]$/.test(key)) {
69+
event.preventDefault();
70+
this.handleNumberKey(index, key);
71+
return;
72+
}
73+
74+
if (key === 'Backspace') {
75+
event.preventDefault();
76+
this.handleBackspace(index);
77+
return;
78+
}
79+
},
80+
handlePaste(event) {
81+
event.preventDefault();
82+
83+
const pastedText = (event.clipboardData || window.clipboardData).getData('text');
84+
const numericOnly = pastedText.replace(/[^0-9]/g, '');
85+
const digitsToFill = Math.min(numericOnly.length, this.totalDigits);
86+
87+
this.digitIndices
88+
.slice(0, digitsToFill)
89+
.forEach(index => {
90+
this.setValue(index, numericOnly[index - 1]);
91+
});
92+
93+
if (numericOnly.length >= this.totalDigits) {
94+
this.updateHiddenField();
95+
}
96+
},
97+
clearAll() {
98+
this.digitIndices.forEach(index => {
99+
this.setValue(index, '');
100+
});
101+
102+
this.$refs.code.value = '';
103+
this.$refs.input1?.focus();
104+
}
105+
}"
106+
>
107+
<div class="flex items-center">
108+
@for ($x = 1; $x <= $digits; $x++)
109+
<input
110+
x-ref="input{{ $x }}"
111+
type="text"
112+
inputmode="numeric"
113+
pattern="[0-9]"
114+
maxlength="1"
115+
autocomplete="off"
116+
@paste="handlePaste"
117+
@keydown="handleKeyDown({{ $x }}, $event)"
118+
@focus="$el.select()"
119+
@input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)"
120+
@class([
121+
'flex size-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent',
122+
'rounded-l-md' => $x === 1,
123+
'rounded-r-md' => $x === $digits,
124+
'-ml-px' => $x > 1,
125+
])
126+
/>
127+
@endfor
128+
</div>
129+
130+
<input
131+
{{ $attributes->except(['digits']) }}
132+
type="hidden"
133+
x-ref="code"
134+
name="{{ $name }}"
135+
minlength="{{ $digits }}"
136+
maxlength="{{ $digits }}"
137+
/>
138+
</div>

0 commit comments

Comments
 (0)