From 7ef8d7e7c4b2efa352ea19b20c174c1b833528af Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 18 Aug 2025 22:37:37 +0530 Subject: [PATCH 01/58] feat: implement two-factor authentication functionality and UI enhancements (#147) --- .gitignore | 1 + .../Auth/AuthenticatedSessionController.php | 6 +- .../Auth/ConfirmablePasswordController.php | 41 -- .../TwoFactorAuthenticationController.php | 24 + app/Http/Requests/Auth/LoginRequest.php | 24 +- app/Models/User.php | 3 +- app/Providers/FortifyServiceProvider.php | 31 ++ bootstrap/providers.php | 1 + composer.json | 1 + config/fortify.php | 159 +++++++ ..._add_two_factor_columns_to_users_table.php | 42 ++ resources/js/components/ui/badge/Badge.vue | 26 ++ resources/js/components/ui/badge/index.ts | 26 ++ resources/js/components/ui/input/Input.vue | 12 + resources/js/layouts/settings/Layout.vue | 4 + resources/js/pages/auth/ConfirmPassword.vue | 2 +- .../js/pages/auth/TwoFactorChallenge.vue | 138 ++++++ resources/js/pages/settings/TwoFactor.vue | 427 ++++++++++++++++++ resources/views/app.blade.php | 1 + routes/auth.php | 7 - routes/settings.php | 6 + .../Settings/TwoFactorAuthenticationTest.php | 87 ++++ 22 files changed, 1017 insertions(+), 52 deletions(-) delete mode 100644 app/Http/Controllers/Auth/ConfirmablePasswordController.php create mode 100644 app/Http/Controllers/Settings/TwoFactorAuthenticationController.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php create mode 100644 resources/js/components/ui/badge/Badge.vue create mode 100644 resources/js/components/ui/badge/index.ts create mode 100644 resources/js/pages/auth/TwoFactorChallenge.vue create mode 100644 resources/js/pages/settings/TwoFactor.vue create mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php diff --git a/.gitignore b/.gitignore index 3847c0df..cb13ba4c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /storage/*.key /storage/pail /vendor +.DS_Store .env .env.backup .env.production diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index dbb93653..3dd6e34e 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -29,7 +29,11 @@ public function create(Request $request): Response */ public function store(LoginRequest $request): RedirectResponse { - $request->authenticate(); + $result = $request->authenticate(); + + if ($result instanceof RedirectResponse) { + return $result; + } $request->session()->regenerate(); diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index fb7d8e0d..00000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php new file mode 100644 index 00000000..61e3aa56 --- /dev/null +++ b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php @@ -0,0 +1,24 @@ +user(); + $confirmed = ! is_null($user->two_factor_confirmed_at); + + return Inertia::render('settings/TwoFactor', [ + 'confirmed' => $confirmed, + 'recoveryCodes' => ! is_null($user->two_factor_secret) ? json_decode(decrypt($user->two_factor_recovery_codes)) : [], + ]); + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 25746424..a29f7088 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -2,12 +2,15 @@ namespace App\Http\Requests\Auth; +use App\Models\User; use Illuminate\Auth\Events\Lockout; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Laravel\Fortify\Features; class LoginRequest extends FormRequest { @@ -41,6 +44,25 @@ public function authenticate(): void { $this->ensureIsNotRateLimited(); + // Check if two-factor authentication is enabled + if (Features::enabled(Features::twoFactorAuthentication())) { + $user = User::where('email', $this->email)->first(); + + // If this user exists, the password is correct, and 2FA is enabled; we want to redirect to the 2FA challenge + if ($user && $user->two_factor_confirmed_at && Hash::check($this->password, $user->password)) { + // Store the user ID and remember preference in the session + $this->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => $this->boolean('remember'), + ]); + + RateLimiter::clear($this->throttleKey()); + + return redirect()->route('two-factor.login'); + } + } + + // Proceed with normal authentication if 2FA is not enabled or the user doesn't have 2FA if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { RateLimiter::hit($this->throttleKey()); @@ -76,7 +98,7 @@ public function ensureIsNotRateLimited(): void } /** - * Get the rate limiting throttle key for the request. + * Get the rate-limiting throttle key for the request. */ public function throttleKey(): string { diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77..a81a74b7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable,TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 00000000..b48e8590 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,31 @@ +by($request->session()->get('login.id')); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d1..0ad9c573 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index ec56e03c..8af58dac 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", + "laravel/fortify": "^1.28", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "tightenco/ziggy": "^2.4" diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 00000000..df49e4f8 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,159 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features, or you can even remove all of these if you need to. + | + */ + + 'features' => [ + // Features::registration(), + // Features::resetPasswords(), + // Features::emailVerification(), + // Features::updateProfileInformation(), + // Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + // 'window' => 0 + ]), + ], + +]; diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..45739efa --- /dev/null +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/resources/js/components/ui/badge/Badge.vue b/resources/js/components/ui/badge/Badge.vue new file mode 100644 index 00000000..d894dfe0 --- /dev/null +++ b/resources/js/components/ui/badge/Badge.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/components/ui/badge/index.ts b/resources/js/components/ui/badge/index.ts new file mode 100644 index 00000000..ac4c0015 --- /dev/null +++ b/resources/js/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) +export type BadgeVariants = VariantProps diff --git a/resources/js/components/ui/input/Input.vue b/resources/js/components/ui/input/Input.vue index 899535e0..51d3c5c0 100644 --- a/resources/js/components/ui/input/Input.vue +++ b/resources/js/components/ui/input/Input.vue @@ -1,5 +1,6 @@