diff --git a/.editorconfig b/.editorconfig index b52cb6ba8..f81cc0d1e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,16 +21,10 @@ indent_size = 2 indent_size = 4 [*.blade.php] -indent_size = 2 +indent_size = 4 [*.js] indent_size = 4 -[*.jsx] -indent_size = 2 - -[*.tsx] -indent_size = 2 - [*.json] indent_size = 4 diff --git a/app/Classes/TwoFactorExtension.php b/app/Classes/TwoFactorExtension.php new file mode 100644 index 000000000..70d7a3348 --- /dev/null +++ b/app/Classes/TwoFactorExtension.php @@ -0,0 +1,94 @@ + int, 'minutes' => int] + */ + public function getRateLimit(string $action): array + { + $defaults = [ + 'setup' => ['attempts' => 3, 'minutes' => 1], + 'enable' => ['attempts' => 3, 'minutes' => 1], + 'disable' => ['attempts' => 3, 'minutes' => 1], + 'verify' => ['attempts' => 5, 'minutes' => 1], + 'action' => ['attempts' => 3, 'minutes' => 5], // Default 3 per 5 mins for sensitive actions + ]; + + $config = static::getConfig(); + $rateLimits = is_array($config) ? ($config['rate_limits'] ?? []) : []; + + return $rateLimits[$action] ?? $defaults[$action]; + } + + /** + * Get method-specific routes configuration if any. + */ + public static function getConfig(): array + { + return []; + } +} diff --git a/app/Console/Commands/DisableTwoFactorCommand.php b/app/Console/Commands/DisableTwoFactorCommand.php new file mode 100644 index 000000000..22ec44f60 --- /dev/null +++ b/app/Console/Commands/DisableTwoFactorCommand.php @@ -0,0 +1,133 @@ +twoFactorService = $twoFactorService; + } + + /** + * Execute the console command. + */ + public function handle() + { + $search = $this->argument('search'); + + if (!$search) { + $search = $this->ask('Please enter User ID, Email, Username or Discord ID'); + } + + if (!$search) { + $this->error('No search term provided.'); + return 1; + } + + $user = User::query() + ->where('id', $search) + ->orWhere('email', $search) + ->orWhere('name', $search) + ->orWhereHas('discordUser', function ($query) use ($search) { + $query->where('id', $search); + }) + ->first(); + + if (!$user) { + $this->error("User not found with term: {$search}"); + return 1; + } + + $this->info("Found User: {$user->name} ({$user->email}) [ID: {$user->id}]"); + + $methods = $user->twoFactorMethods()->where('is_enabled', true)->get(); + + if ($methods->isEmpty()) { + $this->warn('This user does not have any 2FA methods enabled.'); + return 0; + } + + $choices = $methods->mapWithKeys(function ($m) { + $label = $this->twoFactorService->getExtension($m->method)?->getSettings('label') ?? ucfirst($m->method); + return [$m->method => "{$label} ({$m->method})"]; + })->toArray(); + + $choices['all'] = 'Disable ALL methods'; + + $selected = $this->choice( + 'Which 2FA methods do you want to disable?', + $choices, + null, + null, + true // Multiple selection + ); + + if (empty($selected)) { + $this->info('Nothing selected. Aborting.'); + return 0; + } + + if (in_array('Disable ALL methods', $selected) || in_array('all', $selected)) { + if ($this->confirm("Are you sure you want to disable ALL 2FA methods for {$user->name}?", true)) { + $user->twoFactorMethods()->delete(); + $this->twoFactorService->clearVerified(request(), $user); + $this->success("Successfully disabled all 2FA methods for {$user->name}."); + } + return 0; + } + + // Map back titles to keys if necessary (Laravel's choice with multiple can return values or keys depending on version/selection) + $methodKeys = []; + foreach ($selected as $choice) { + $key = array_search($choice, $choices); + if ($key !== false) { + $methodKeys[] = $key; + } else { + // If it already returned the key + if (isset($choices[$choice])) { + $methodKeys[] = $choice; + } + } + } + + if ($this->confirm("Disable selected methods: " . implode(', ', $methodKeys) . "?", true)) { + $user->twoFactorMethods()->whereIn('method', $methodKeys)->delete(); + + // If we disabled everything, clear verified state + if ($user->twoFactorMethods()->where('is_enabled', true)->count() === 0) { + $this->twoFactorService->clearVerified(request(), $user); + } + + $this->success("Successfully disabled " . implode(', ', $methodKeys) . " for {$user->name}."); + } + + return 0; + } + + protected function success($message) + { + $this->output->writeln(" SUCCESS $message"); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 54fefc1fd..6d10d90a0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -33,6 +33,7 @@ protected function schedule(Schedule $schedule) $schedule->command('payments:open:clear')->daily(); $schedule->command('coupons:delete')->hourly(); $schedule->command('vouchers:delete')->hourly(); + $schedule->command('model:prune')->daily(); //log cronjob activity $schedule->call(function () { diff --git a/app/Extensions/TwoFactor/Dummy/DummyExtension.php b/app/Extensions/TwoFactor/Dummy/DummyExtension.php new file mode 100644 index 000000000..ef5259441 --- /dev/null +++ b/app/Extensions/TwoFactor/Dummy/DummyExtension.php @@ -0,0 +1,97 @@ + 'dummy', + 'label' => __('Dummy 2FA'), + 'icon' => 'fas fa-flask', + 'description' => __('A temporary non-production method for testing modular 2FA.'), + ]; + + return $key ? ($settings[$key] ?? null) : $settings; + } + + public function isAvailable(User $user): bool + { + return app()->environment('local'); + } + + public function getSettingsView(): string + { + if (!app()->environment('local')) { + abort(403); + } + + return 'twofactor_dummy::profile_card'; + } + + public function getChallengeView(): string + { + if (!app()->environment('local')) { + abort(403); + } + + return 'twofactor_dummy::auth.two-factor.dummy-challenge'; + } + + public function verify(Request $request): bool + { + if (!app()->environment('local')) { + abort(403); + } + + return $request->input('code') === '123456'; + } + + public function setup(Request $request) + { + if (!app()->environment('local')) { + abort(403); + } + + return response()->json(['message' => 'Dummy setup ready. Use code 123456 to enable.']); + } + + public function enable(Request $request) + { + if (!app()->environment('local')) { + abort(403); + } + + if ($request->input('code') !== '123456') { + return response()->json(['errors' => ['code' => ['Use 123456']]], 422); + } + + UserTwoFactorMethod::updateOrCreate( + ['user_id' => $request->user()->id, 'method' => 'dummy'], + ['is_enabled' => true] + ); + + return response()->json(['message' => 'Dummy 2FA enabled!']); + } + + /** + * NOTE: This is a dummy method for development only. + * In a production-ready extension, this method SHOULD require + * password or 2FA code verification before disabling. + */ + public function disable(Request $request) + { + if (!app()->environment('local')) { + abort(403); + } + + $request->user()->twoFactorMethods()->where('method', 'dummy')->delete(); + return response()->json(['message' => 'Dummy 2FA disabled.']); + } +} diff --git a/app/Extensions/TwoFactor/Dummy/views/auth/two-factor/dummy-challenge.blade.php b/app/Extensions/TwoFactor/Dummy/views/auth/two-factor/dummy-challenge.blade.php new file mode 100644 index 000000000..ca3d5d826 --- /dev/null +++ b/app/Extensions/TwoFactor/Dummy/views/auth/two-factor/dummy-challenge.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.app') + +@section('content') + @php($suppressSweetAlert2 = true) + + +
+
+ +
+ +

Enter 123456 to pass.

+ +
+ @csrf +
+ +
+ +
+
+
+
+ +@endsection diff --git a/app/Extensions/TwoFactor/Dummy/views/profile_card.blade.php b/app/Extensions/TwoFactor/Dummy/views/profile_card.blade.php new file mode 100644 index 000000000..46f999016 --- /dev/null +++ b/app/Extensions/TwoFactor/Dummy/views/profile_card.blade.php @@ -0,0 +1,67 @@ +@php($dummy = Auth::user()->twoFactorMethods->where('method', 'dummy')->where('is_enabled', true)->first()) +
+
+
+
+ +
+ + + @if($dummy) + {{ __('Enabled') }} + @else + {{ __('Disabled') }} + @endif +
+ + +
+
{{ $method->getSettings('label') }}
+
{{ $method->getSettings('description') }}
+
+
+ +
+ @if($dummy) + {{ __('Enabled') }} + @else + {{ __('Disabled') }} + @endif +
+
+ +
+ +
+ {{ __('Enable') }} +
+ + + +
+
+
+
+ +@push('scripts') + +@endpush diff --git a/app/Extensions/TwoFactor/README.md b/app/Extensions/TwoFactor/README.md new file mode 100644 index 000000000..c8d12c770 --- /dev/null +++ b/app/Extensions/TwoFactor/README.md @@ -0,0 +1,104 @@ +# Modular Two-Factor Authentication (2FA) System + +CtrlPanel.gg features a fully modular 2FA system that allows developers to add new authentication methods (e.g., SMS, Email, WebAuthn) without modifying the core codebase. Each method is a self-contained extension. + +--- + +## 1. Directory Structure + +Every 2FA extension resides in `app/Extensions/TwoFactor/{MethodName}/`. + +```text +app/Extensions/TwoFactor/YourMethod/ +├── migrations/ # Database changes specific to this method +├── views/ # Blade templates (settings, challenges) +│ ├── auth/two-factor/ # Recommended path for challenge views +│ └── profile_card.blade.php +├── YourMethodExtension.php # The main extension class +├── routes.php # (Optional) Custom routes for this method +└── YourMethodService.php # (Optional) Helper services +``` + +--- + +## 2. The Extension Class + +Your main class must extend `App\Classes\TwoFactorExtension`. + +### Required Methods +- `getSettings(?string $key = null)`: Method configuration metadata array (contains `name`, `label`, `icon`, `description`) or a specific property if `$key` is passed. +- `getSettingsView()`: Name of the Blade view for the profile card. +- `getChallengeView()`: Name of the Blade view for the login screen. +- `verify(Request $request)`: Logic to validate the user's code/token. +- `setup(Request $request)`: Initialization logic (e.g., generating a QR code). +- `enable(Request $request)`: Final logic to enable the method for the user. +- `disable(Request $request)`: Logic to remove the method from the user. + +--- + +## 3. Advanced Features + +### Dynamic Rate Limiting +You can control the load of your extension by overriding `getRateLimit(string $action)`. This prevents abuse, such as OTP spamming. + +```php +public function getRateLimit(string $action): array +{ + if ($action === 'action') { // Custom actions like "Resend Email" + return ['attempts' => 1, 'minutes' => 5]; // 1 request every 5 minutes + } + return parent::getRateLimit($action); +} +``` + +### Action Whitelisting +To call custom methods on your extension via the universal route `POST /profile/security/2fa/{method}/{action}`, you must whitelist them in `getAllowedActions()`. + +```php +public function getAllowedActions(): array +{ + return ['resendEmail', 'verifyCustomToken']; +} +``` + +### Database & Migrations +If your method needs custom columns (e.g., `phone_number`), create a migration in your extension's `migrations/` folder. It will be loaded automatically. +**Note:** Use `Schema::table('user_two_factor_methods', ...)` to extend the core table. + +### Custom Routes +If the standard `setup`, `enable`, `disable`, and `action` routes are not enough for your method, you can define your own routes in a `routes.php` file within your extension directory. + +The system will automatically load this file. It is recommended to use your extension's name as a prefix and apply the necessary middleware: + +```php +// app/Extensions/TwoFactor/YourMethod/routes.php +use Illuminate\Support\Facades\Route; + +Route::middleware(['web', 'auth', 'two_factor.verified'])->group(function () { + Route::post('profile/security/2fa/your-method/special-action', [YourController::class, 'handle']) + ->name('profile.2fa.your-method.special-action'); +}); +``` + +--- + +## 4. Theming & View Overrides + +The system uses Laravel’s `loadViewsFrom` with a dynamic namespace pattern: `twofactor_{method_name}`. + +### How It Works +When you call `view('twofactor_totp::profile_card')`, the system searches in this order: +1. **Active Theme**: `themes/{theme}/views/vendor/twofactor_totp/profile_card.blade.php` +2. **Extension Default**: `app/Extensions/TwoFactor/Totp/views/profile_card.blade.php` + +### Common Override Paths +- **Method Picker**: `themes/{theme}/views/auth/two-factor/picker.blade.php` (Core view) +- **Method Card**: `themes/{theme}/views/vendor/twofactor_{method}/profile_card.blade.php` +- **Method Challenge**: `themes/{theme}/views/vendor/twofactor_{method}/auth/two-factor/{method}-challenge.blade.php` + +--- + +## 5. Security Best Practices +1. **Environment Checks**: For development-only methods, use `app()->environment('local')` in `isAvailable()`. +2. **Encryption**: Always `encrypt()` sensitive data (secrets, tokens) before saving to the database and `decrypt()` when retrieving. +3. **Verification**: Always require a password check or a valid 2FA code in the `disable()` method. diff --git a/app/Extensions/TwoFactor/Totp/RecoveryCodeService.php b/app/Extensions/TwoFactor/Totp/RecoveryCodeService.php new file mode 100644 index 000000000..491b0dde9 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/RecoveryCodeService.php @@ -0,0 +1,74 @@ +getExtension('totp') + ?->getRateLimit('recovery_code') ?? ['attempts' => 5, 'minutes' => 10]; + + $cacheKey = "2fa.recovery_attempts.{$user->id}"; + $attempts = Cache::get($cacheKey, 0); + + if ($attempts >= $limit['attempts']) { + throw ValidationException::withMessages([ + 'code' => [__('Too many recovery code attempts. Please try again in :minutes minutes.', ['minutes' => $limit['minutes']])], + ]); + } + + $code = strtoupper(preg_replace('/\s+/', '', $code)); + + $method = $user->twoFactorMethods->where('method', 'totp')->first(); + + if (!$method || !$method->totp_recovery_codes) { + return false; + } + + $recoveryCodes = decrypt($method->totp_recovery_codes); + $matchedIndex = null; + + foreach ($recoveryCodes as $index => $storedCode) { + // Using hash_equals for constant-time comparison across all codes + if (hash_equals($storedCode, $code)) { + $matchedIndex = $index; + } + } + + if ($matchedIndex !== null) { + // Burn the code + unset($recoveryCodes[$matchedIndex]); + $method->totp_recovery_codes = encrypt(array_values($recoveryCodes)); + $method->save(); + + Cache::forget($cacheKey); + return true; + } + + Cache::put($cacheKey, $attempts + 1, now()->addMinutes($limit['minutes'])); + return false; + } +} diff --git a/app/Extensions/TwoFactor/Totp/TotpExtension.php b/app/Extensions/TwoFactor/Totp/TotpExtension.php new file mode 100644 index 000000000..a8ce37471 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/TotpExtension.php @@ -0,0 +1,227 @@ +totpService = $totpService; + $this->recoveryCodeService = $recoveryCodeService; + $this->twoFactorService = $twoFactorService; + } + + public function getSettings(?string $key = null): mixed + { + $settings = [ + 'name' => 'totp', + 'label' => __('Authenticator App'), + 'icon' => 'fas fa-mobile-alt', + 'description' => __('Use an app to get codes'), + ]; + + return $key ? ($settings[$key] ?? null) : $settings; + } + + public function getSettingsView(): string + { + return 'twofactor_totp::profile_card'; + } + + public function getChallengeView(): string + { + return 'twofactor_totp::auth.two-factor.totp-challenge'; + } + + public function verify(Request $request): bool + { + $request->validate([ + 'code' => 'required|string', + ]); + + $user = $request->user(); + $method = $user->twoFactorMethods()->where('method', 'totp')->first(); + if (!$method) { + return false; + } + + return $this->verifyAnyCode($user, $method, $request->input('code')); + } + + public function setup(Request $request) + { + $user = $request->user(); + + // Discard any previous pending secret + $request->session()->forget('totp_pending_secret'); + + $secret = $this->totpService->generateSecret(); + $request->session()->put('totp_pending_secret', $secret); + + $qrSvg = $this->totpService->getQrCodeSvg($user->email, $secret); + + // Format secret in groups of 4 for readability + $formattedSecret = implode(' ', str_split($secret, 4)); + + return response()->json([ + 'qr_svg' => $qrSvg, + 'secret' => $formattedSecret, + ]); + } + + public function enable(Request $request) + { + $request->validate([ + 'code' => 'required|string', + 'password' => 'required|string', + ]); + + $user = $request->user(); + $pendingSecret = $request->session()->get('totp_pending_secret'); + + if (!$pendingSecret) { + return response()->json(['message' => __('Setup session expired. Please try again.')], 422); + } + + if (!Hash::check($request->input('password'), $user->password)) { + throw ValidationException::withMessages([ + 'password' => [__('The provided password does not match your current password.')], + ]); + } + + if (!$this->totpService->verifyCode($pendingSecret, $request->input('code'))) { + throw ValidationException::withMessages([ + 'code' => [__('The provided two-factor authentication code was invalid.')], + ]); + } + + // Persist TOTP method + $recoveryCodes = $this->recoveryCodeService->generate(); + + UserTwoFactorMethod::updateOrCreate( + ['user_id' => $user->id, 'method' => 'totp'], + [ + 'is_enabled' => true, + 'totp_secret' => encrypt($pendingSecret), + 'totp_recovery_codes' => encrypt($recoveryCodes), + ] + ); + + $request->session()->forget('totp_pending_secret'); + + // Mark as verified for current session + $this->twoFactorService->markVerified($request, $user); + + return response()->json([ + 'recovery_codes' => $recoveryCodes, + ]); + } + + public function disable(Request $request) + { + $request->validate([ + 'code' => 'required|string', + 'password' => 'required|string', + ]); + + $user = $request->user(); + + if (!Hash::check($request->input('password'), $user->password)) { + throw ValidationException::withMessages([ + 'password' => [__('The provided password does not match your current password.')], + ]); + } + + $method = $user->twoFactorMethods()->where('method', 'totp')->first(); + if (!$method) { + return response()->json(['message' => __('Two-factor authentication is not enabled.')], 422); + } + + if (!$this->verifyAnyCode($user, $method, $request->input('code'))) { + throw ValidationException::withMessages([ + 'code' => [__('The provided two-factor authentication code was invalid.')], + ]); + } + + $method->delete(); + + return response()->json(['message' => __('Two-factor authentication has been disabled.')]); + } + + public function showRecoveryCodes(Request $request) + { + $request->validate([ + 'code' => 'required|string', + 'password' => 'required|string', + ]); + + $user = $request->user(); + + if (!Hash::check($request->input('password'), $user->password)) { + throw ValidationException::withMessages([ + 'password' => [__('The provided password does not match your current password.')], + ]); + } + + $method = $user->twoFactorMethods()->where('method', 'totp')->first(); + if (!$method) { + return response()->json(['message' => __('Two-factor authentication is not enabled.')], 422); + } + + if (!$this->verifyAnyCode($user, $method, $request->input('code'))) { + throw ValidationException::withMessages([ + 'code' => [__('The provided two-factor authentication code was invalid.')], + ]); + } + + $method->refresh(); + + return response()->json([ + 'recovery_codes' => decrypt($method->totp_recovery_codes), + ]); + } + + private function verifyAnyCode(User $user, UserTwoFactorMethod $method, string $rawCode): bool + { + $code = preg_replace('/\s+/', '', $rawCode); + + if (strlen($code) === 6 && ctype_digit($code)) { + if ($method->totp_secret) { + return $this->totpService->verifyCode(decrypt($method->totp_secret), $code); + } + } elseif (strlen($code) === 8 && ctype_alnum($code)) { + return $this->recoveryCodeService->verify($user, $code); + } + + return false; + } + + public function getAllowedActions(): array + { + return ['showRecoveryCodes']; + } + + public static function getConfig(): array + { + return [ + 'name' => 'TOTP', + 'rate_limits' => [ + 'recovery_code' => ['attempts' => 5, 'minutes' => 10], + ], + ]; + } +} diff --git a/app/Extensions/TwoFactor/Totp/TotpService.php b/app/Extensions/TwoFactor/Totp/TotpService.php new file mode 100644 index 000000000..a89f1f5a8 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/TotpService.php @@ -0,0 +1,36 @@ +text('totp_secret')->nullable(); + $table->text('totp_recovery_codes')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_two_factor_methods', function (Blueprint $table) { + $table->dropColumn(['totp_secret', 'totp_recovery_codes']); + }); + } +}; diff --git a/app/Extensions/TwoFactor/Totp/views/auth/two-factor/totp-challenge.blade.php b/app/Extensions/TwoFactor/Totp/views/auth/two-factor/totp-challenge.blade.php new file mode 100644 index 000000000..37302d649 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/views/auth/two-factor/totp-challenge.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') + +@section('content') +@php($suppressSweetAlert2 = true) + + +
+
+ +
+ + +

+ {{ __('Please enter your 6-digit TOTP code from your authenticator app or an 8-character recovery code.') }} +

+ +
+ @csrf + +
+
+ +
+
+ +
+
+
+ @error('code') + + {{ $message }} + + @enderror +
+ +
+
+ +
+
+
+

+ + {{ __('Logout') }} + +

+ +
+ @csrf +
+
+
+
+ +@endsection diff --git a/app/Extensions/TwoFactor/Totp/views/modals/totp-disable-modal.blade.php b/app/Extensions/TwoFactor/Totp/views/modals/totp-disable-modal.blade.php new file mode 100644 index 000000000..0460911fb --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/views/modals/totp-disable-modal.blade.php @@ -0,0 +1,103 @@ + + + diff --git a/app/Extensions/TwoFactor/Totp/views/modals/totp-setup-modal.blade.php b/app/Extensions/TwoFactor/Totp/views/modals/totp-setup-modal.blade.php new file mode 100644 index 000000000..a22238780 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/views/modals/totp-setup-modal.blade.php @@ -0,0 +1,228 @@ + + + diff --git a/app/Extensions/TwoFactor/Totp/views/modals/totp-view-recovery-modal.blade.php b/app/Extensions/TwoFactor/Totp/views/modals/totp-view-recovery-modal.blade.php new file mode 100644 index 000000000..caba93801 --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/views/modals/totp-view-recovery-modal.blade.php @@ -0,0 +1,158 @@ + + + diff --git a/app/Extensions/TwoFactor/Totp/views/profile_card.blade.php b/app/Extensions/TwoFactor/Totp/views/profile_card.blade.php new file mode 100644 index 000000000..07517922d --- /dev/null +++ b/app/Extensions/TwoFactor/Totp/views/profile_card.blade.php @@ -0,0 +1,70 @@ +@php($totp = Auth::user()->twoFactorMethods->where('method', 'totp')->where('is_enabled', true)->first()) +
+
+
+
+ +
+ + + @if($totp) + {{ __('Enabled') }} + @else + {{ __('Disabled') }} + @endif +
+ + +
+
{{ $method->getSettings('label') }}
+
{{ $method->getSettings('description') }}
+
+
+ +
+ @if($totp) + {{ __('Enabled') }} + @else + {{ __('Disabled') }} + @endif +
+
+ +
+ +
+ {{ __('Enable') }} +
+ + +
+
+ + @if($totp) +
+ +
+ @endif +
+
+ +@push('modals') + @include('twofactor_totp::modals.totp-setup-modal') + @include('twofactor_totp::modals.totp-view-recovery-modal') + @include('twofactor_totp::modals.totp-disable-modal') +@endpush + +@push('scripts') + +@endpush diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 0dce17651..205c454f6 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -88,6 +88,12 @@ public function show(User $user, LocaleSettings $locale_settings, GeneralSetting { $this->checkPermission(self::READ_PERMISSION); + $user->load('twoFactorMethods'); + $enabled2faMethods = $user->twoFactorMethods + ->where('is_enabled', true) + ->pluck('method') + ->implode(' / '); + $referralRecords = DB::table('user_referrals')->where('referral_id', '=', $user->id)->get(); $allReferrals = []; @@ -136,6 +142,7 @@ public function show(User $user, LocaleSettings $locale_settings, GeneralSetting return view('admin.users.show')->with([ 'user' => $user, 'referrals' => $allReferrals, + 'enabled2faMethods' => $enabled2faMethods, 'locale_datatables' => $locale_settings->datatables, 'credits_display_name' => $general_settings->credits_display_name ]); @@ -496,7 +503,7 @@ public function dataTable(Request $request) { $this->checkPermission(self::READ_PERMISSION); - $query = User::with('discordUser') + $query = User::with('discordUser', 'twoFactorMethods') ->withCount('servers') ->leftJoin('model_has_roles', 'users.id', '=', 'model_has_roles.model_id') ->leftJoin('roles', 'model_has_roles.role_id', '=', 'roles.id') @@ -513,6 +520,13 @@ public function dataTable(Request $request) ->addColumn('verified', function (User $user) { return $user->getVerifiedStatus(); }) + ->addColumn('two_factor', function (User $user) { + $enabled = $user->twoFactorMethods->where('is_enabled', true)->isNotEmpty(); + if ($enabled) { + return '' . __('Enabled') . ''; + } + return '' . __('Disabled') . ''; + }) ->addColumn('discordId', function (User $user) { return $user->discordUser ? $user->discordUser->id : ''; }) @@ -553,7 +567,7 @@ public function dataTable(Request $request) return '' . e($user->name) . ''; }) ->orderColumn('role', 'role_name $1') - ->rawColumns(['avatar', 'name', 'credits', 'role', 'usage', 'actions']) + ->rawColumns(['avatar', 'name', 'credits', 'role', 'usage', 'two_factor', 'actions']) ->make(); } } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index e0f4455bb..b5716553f 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Providers\RouteServiceProvider; +use App\Services\TwoFactor\TwoFactorService; use App\Settings\GeneralSettings; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Http\Request; @@ -31,14 +32,17 @@ class LoginController extends Controller */ protected $redirectTo = RouteServiceProvider::HOME; + protected $twoFactorService; + /** * Create a new controller instance. * * @return void */ - public function __construct() + public function __construct(TwoFactorService $twoFactorService) { $this->middleware('guest')->except('logout'); + $this->twoFactorService = $twoFactorService; } /** @@ -94,4 +98,54 @@ public function login(Request $request, GeneralSettings $general_settings) return $this->sendFailedLoginResponse($request); } + + /** + * The user has been authenticated. + * + * @param \Illuminate\Http\Request $request + * @param mixed $user + * @return mixed + */ + protected function authenticated(Request $request, $user) + { + $methods = $this->twoFactorService->enabledMethods($user); + + if ($methods->isNotEmpty()) { + // Redirect to 2FA challenge if the user has enabled methods but is not yet verified + // (this is typically the case immediately after a successful password login). + if (!$this->twoFactorService->isVerified($request, $user)) { + return redirect()->route('login.2fa.challenge'); + } + } + + return redirect()->intended($this->redirectPath()); + } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse + */ + public function logout(Request $request) + { + $user = Auth::user(); + if ($user) { + $this->twoFactorService->clearVerified($request, $user); + } + + $this->guard()->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + if ($response = $this->loggedOut($request)) { + return $response; + } + + return $request->wantsJson() + ? new \Illuminate\Http\JsonResponse([], 204) + : redirect('/'); + } } diff --git a/app/Http/Controllers/Auth/TwoFactor/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactor/TwoFactorController.php new file mode 100644 index 000000000..631d24757 --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactor/TwoFactorController.php @@ -0,0 +1,40 @@ +twoFactorService = $twoFactorService; + } + + /** + * Show the 2FA challenge method picker or redirect to the only enabled method. + */ + public function showChallenge(Request $request) + { + $user = $request->user(); + $enabledMethods = $this->twoFactorService->enabledMethods($user); + + if ($enabledMethods->isEmpty()) { + return redirect()->intended(route('home')); + } + + if ($enabledMethods->count() === 1) { + $method = $enabledMethods->first()->method; + return redirect()->route('login.2fa.method', ['method' => $method]); + } + + // Multiple methods enabled: show picker + $methods = $enabledMethods->map(fn ($m) => $this->twoFactorService->getExtension($m->method))->filter(); + + return view('auth.two-factor.picker', compact('methods')); + } +} diff --git a/app/Http/Controllers/Auth/TwoFactor/TwoFactorExtensionController.php b/app/Http/Controllers/Auth/TwoFactor/TwoFactorExtensionController.php new file mode 100644 index 000000000..d1819a2dc --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactor/TwoFactorExtensionController.php @@ -0,0 +1,113 @@ +twoFactorService = $twoFactorService; + } + + /** + * Show the 2FA challenge view for a specific method. + */ + public function showChallenge(Request $request, string $method) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !$this->twoFactorService->isMethodEnabled($request->user(), $method)) { + return redirect()->route('login.2fa.challenge'); + } + + return view($extension->getChallengeView()); + } + + /** + * Verify the 2FA challenge. + */ + public function verify(Request $request, string $method) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !$this->twoFactorService->isMethodEnabled($request->user(), $method)) { + abort(403); + } + + if ($extension->verify($request)) { + $this->twoFactorService->markVerified($request, $request->user()); + return redirect()->intended(route('home')); + } + + throw ValidationException::withMessages([ + 'code' => [__('The provided two-factor authentication code was invalid.')], + ]); + } + + /** + * Start the setup process for a 2FA method. + */ + public function setup(Request $request, string $method) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !$extension->isAvailable($request->user())) { + abort(403); + } + + return $extension->setup($request); + } + + /** + * Enable a 2FA method. + */ + public function enable(Request $request, string $method) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !$extension->isAvailable($request->user())) { + abort(403); + } + + return $extension->enable($request); + } + + /** + * Disable a 2FA method. + */ + public function disable(Request $request, string $method) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !$this->twoFactorService->isMethodEnabled($request->user(), $method)) { + abort(403); + } + + return $extension->disable($request); + } + + /** + * Method-specific custom actions (like showing recovery codes). + */ + public function action(Request $request, string $method, string $action) + { + $extension = $this->twoFactorService->getExtension($method); + + if (!$extension || !method_exists($extension, $action)) { + abort(404); + } + + if (!in_array($action, $extension->getAllowedActions(), true)) { + abort(403); + } + + return $extension->{$action}($request); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index c68f2cd03..60b0534a3 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -4,6 +4,7 @@ use App\Facades\Currency; use App\Models\User; +use App\Services\TwoFactor\TwoFactorService; use App\Settings\UserSettings; use App\Settings\PterodactylSettings; use App\Classes\PterodactylClient; @@ -18,18 +19,25 @@ class ProfileController extends Controller { private $pterodactyl; + protected $twoFactorService; - public function __construct(PterodactylSettings $ptero_settings) + public function __construct(PterodactylSettings $ptero_settings, TwoFactorService $twoFactorService) { $this->pterodactyl = new PterodactylClient($ptero_settings); + $this->twoFactorService = $twoFactorService; } /** Display a listing of the resource. */ public function index(UserSettings $user_settings, DiscordSettings $discord_settings, ReferralSettings $referral_settings) { + $user = Auth::user(); + $user->load('twoFactorMethods'); + + $availableMethods = $this->twoFactorService->getAvailableMethodsForUser($user); return view('profile.index')->with([ - 'user' => Auth::user(), + 'user' => $user, + 'availableMethods' => $availableMethods, // raw numeric value for logical checks; formatting occurs in blades when needed 'credits_reward_after_verify_discord' => $user_settings->credits_reward_after_verify_discord, 'force_email_verification' => $user_settings->force_email_verification, diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 131548a35..11fa752b4 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -76,6 +76,8 @@ class Kernel extends HttpKernel 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'canAccessDocsPage' => \App\Http\Middleware\CanAccessDocsPage::class, + 'two_factor.required' => \App\Http\Middleware\RequireTwoFactor::class, + 'two_factor.verified' => \App\Http\Middleware\EnsureTwoFactorVerified::class, ]; } diff --git a/app/Http/Middleware/EnsureTwoFactorVerified.php b/app/Http/Middleware/EnsureTwoFactorVerified.php new file mode 100644 index 000000000..39cbccdaa --- /dev/null +++ b/app/Http/Middleware/EnsureTwoFactorVerified.php @@ -0,0 +1,42 @@ +twoFactorService = $twoFactorService; + } + + /** + * Handle an incoming request. + * + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + */ + public function handle(Request $request, Closure $next): Response + { + if ($request->session()->has('previousUser')) { + return $next($request); + } + + $user = $request->user(); + + if (!$user) { + return $next($request); + } + + if ($this->twoFactorService->enabledMethods($user)->isNotEmpty() && !$this->twoFactorService->isVerified($request, $user)) { + return redirect()->route('login.2fa.challenge'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/RequireTwoFactor.php b/app/Http/Middleware/RequireTwoFactor.php new file mode 100644 index 000000000..62e12cb6b --- /dev/null +++ b/app/Http/Middleware/RequireTwoFactor.php @@ -0,0 +1,59 @@ +twoFactorService = $twoFactorService; + } + + /** + * Handle an incoming request. + * + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + */ + public function handle(Request $request, Closure $next): Response + { + if ($request->session()->has('previousUser')) { + return $next($request); + } + + $user = $request->user(); + + // if NOT authenticated > skip (outer auth middleware handles it) + if (!$user) { + return $next($request); + } + + // if user has no enabled 2FA methods > pass through + if ($this->twoFactorService->enabledMethods($user)->isEmpty()) { + return $next($request); + } + + // if verified > pass through + if ($this->twoFactorService->isVerified($request, $user)) { + return $next($request); + } + + // Avoid infinite loop if we are already on a 2FA route + if ($request->is('login/2fa*') || $request->routeIs('login.2fa.*')) { + return $next($request); + } + + // store intended URL in session + if ($request->isMethod('GET') && !$request->ajax()) { + $request->session()->put('url.intended', $request->fullUrl()); + } + + return redirect()->route('login.2fa.challenge'); + } +} diff --git a/app/Models/TwoFactorVerifiedToken.php b/app/Models/TwoFactorVerifiedToken.php new file mode 100644 index 000000000..92f9974c3 --- /dev/null +++ b/app/Models/TwoFactorVerifiedToken.php @@ -0,0 +1,35 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function prunable() + { + return static::where('expires_at', '<', now()); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 82a34a3b9..1c3aa6d5b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -240,6 +240,22 @@ public function discordUser() return $this->hasOne(DiscordUser::class); } + /** + * @return HasMany + */ + public function twoFactorMethods(): HasMany + { + return $this->hasMany(UserTwoFactorMethod::class); + } + + /** + * @return HasMany + */ + public function twoFactorVerifiedTokens(): HasMany + { + return $this->hasMany(TwoFactorVerifiedToken::class); + } + public function sendEmailVerificationNotification() { try { diff --git a/app/Models/UserTwoFactorMethod.php b/app/Models/UserTwoFactorMethod.php new file mode 100644 index 000000000..1ad9cf8e6 --- /dev/null +++ b/app/Models/UserTwoFactorMethod.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/ReferralNotification.php b/app/Notifications/ReferralNotification.php index a760da84c..7d6325875 100644 --- a/app/Notifications/ReferralNotification.php +++ b/app/Notifications/ReferralNotification.php @@ -58,7 +58,7 @@ public function toArray($notifiable)

You received '. Currency::formatForDisplay($this->reward) . ' ' . $this->credits_display_name . '

because ' . e($this->ref_user->name) . ' registered with your Referral-Code!

Thank you very much for supporting us!.

-

'.config('app.name', 'Laravel').'

+

'.config('app.name', 'CtrlPanel.gg').'

', ]; } diff --git a/app/Notifications/ServerCreationError.php b/app/Notifications/ServerCreationError.php index f575d7d37..4ed220763 100644 --- a/app/Notifications/ServerCreationError.php +++ b/app/Notifications/ServerCreationError.php @@ -53,7 +53,7 @@ public function toArray($notifiable)

Hello {$userName}, An unexpected error has occurred...

There was a problem creating your server on our pterodactyl panel. There are likely no allocations or rooms left on the selected node. Please contact one of our support members through our discord server to get this resolved asap!

We thank you for your patience and our deepest apologies for this inconvenience.

-

".config('app.name', 'Laravel').'

+

".config('app.name', 'CtrlPanel.gg').'

', ]; } diff --git a/app/Notifications/ServersSuspendedNotification.php b/app/Notifications/ServersSuspendedNotification.php index 7b1f1a3e8..dee714ead 100644 --- a/app/Notifications/ServersSuspendedNotification.php +++ b/app/Notifications/ServersSuspendedNotification.php @@ -67,7 +67,7 @@ public function toArray($notifiable)
'.__('Your servers have been suspended!').'

'.__('To automatically re-enable your server/s, you need to purchase more credits.').'

'.__('If you have any questions please let us know.').'

-

'.__('Regards').',
'.config('app.name', 'Laravel').'

+

'.__('Regards').',
'.config('app.name', 'CtrlPanel.gg').'

', ]; } diff --git a/app/Notifications/ServersUnsuspendedNotification.php b/app/Notifications/ServersUnsuspendedNotification.php index 8c0e35412..b1015c21c 100644 --- a/app/Notifications/ServersUnsuspendedNotification.php +++ b/app/Notifications/ServersUnsuspendedNotification.php @@ -59,7 +59,7 @@ public function toArray(object $notifiable): array 'content' => '
'.__('Your servers have been unsuspended').'

'.__('We appreciate your continued trust in our services. If you have any questions or need assistance, feel free to reach out to our support team.').'

-

'.__('Regards').',
'.config('app.name', 'Laravel').'

+

'.__('Regards').',
'.config('app.name', 'CtrlPanel.gg').'

' ]; } diff --git a/app/Notifications/WelcomeMessage.php b/app/Notifications/WelcomeMessage.php index e01bf1350..1ff08c235 100644 --- a/app/Notifications/WelcomeMessage.php +++ b/app/Notifications/WelcomeMessage.php @@ -101,7 +101,7 @@ public function toArray($notifiable)
'.__('Information').'

'.__('This dashboard can be used to create and delete servers').'.
'.__('These servers can be used and managed on our pterodactyl panel').'.
'.__('If you have any questions, please join our Discord server and #create-a-ticket').'.

'.__('We hope you can enjoy this hosting experience and if you have any suggestions please let us know').'!

-

'.__('Regards').',
'.config('app.name', 'Laravel').'

+

'.__('Regards').',
'.config('app.name', 'CtrlPanel.gg').'

', ]; } diff --git a/app/Providers/ExtensionServiceProvider.php b/app/Providers/ExtensionServiceProvider.php index 29dce0a16..8aba288cc 100644 --- a/app/Providers/ExtensionServiceProvider.php +++ b/app/Providers/ExtensionServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; class ExtensionServiceProvider extends ServiceProvider { @@ -24,23 +25,37 @@ public function boot(): void return; } - $extensionNamespaces = glob($extensionsBasePath . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: []; + $namespaceDirectories = glob($extensionsBasePath . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: []; - foreach ($extensionNamespaces as $extensionNamespace) { - $extensions = glob($extensionNamespace . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: []; - foreach ($extensions as $extension) { - $routesFile = $extension . DIRECTORY_SEPARATOR . 'routes.php'; - if (!is_file($routesFile)) { - continue; + foreach ($namespaceDirectories as $namespaceDirectory) { + $namespaceName = basename($namespaceDirectory); + $extensionDirectories = glob($namespaceDirectory . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: []; + + foreach ($extensionDirectories as $extensionDirectory) { + $extensionName = basename($extensionDirectory); + + // Load Routes + $routesFile = $extensionDirectory . DIRECTORY_SEPARATOR . 'routes.php'; + if (is_file($routesFile)) { + $resolvedPath = realpath($routesFile); + $basePath = realpath($extensionsBasePath); + if ($resolvedPath && $basePath && str_starts_with($resolvedPath, $basePath . DIRECTORY_SEPARATOR)) { + $this->loadRoutesFrom($resolvedPath); + } } - $resolvedRoutesFile = realpath($routesFile); - $normalizedBasePath = rtrim($extensionsBasePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - if ($resolvedRoutesFile === false || !str_starts_with($resolvedRoutesFile, $normalizedBasePath)) { - continue; + // Load Views + $viewsDirectory = $extensionDirectory . DIRECTORY_SEPARATOR . 'views'; + if (is_dir($viewsDirectory)) { + $viewNamespace = Str::lower($namespaceName . '_' . $extensionName); + $this->loadViewsFrom($viewsDirectory, $viewNamespace); } - $this->loadRoutesFrom($resolvedRoutesFile); + // Load Migrations + $migrationsDirectory = $extensionDirectory . DIRECTORY_SEPARATOR . 'migrations'; + if (is_dir($migrationsDirectory)) { + $this->loadMigrationsFrom($migrationsDirectory); + } } } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 668565eab..1839fc199 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -52,5 +52,44 @@ protected function configureRateLimiting() RateLimiter::for('web', function (Request $request) { return Limit::perMinute(40)->by($request->user()?->id ?: $request->ip()); }); + RateLimiter::for('2fa.verify', function (Request $request) { + $method = $request->route('method'); + $limit = app(\App\Services\TwoFactor\TwoFactorService::class)->getExtension($method)?->getRateLimit('verify') + ?? ['attempts' => 5, 'minutes' => 1]; + + return Limit::perMinutes($limit['minutes'], $limit['attempts'])->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('2fa.setup', function (Request $request) { + $method = $request->route('method'); + $limit = app(\App\Services\TwoFactor\TwoFactorService::class)->getExtension($method)?->getRateLimit('setup') + ?? ['attempts' => 3, 'minutes' => 1]; + + return Limit::perMinutes($limit['minutes'], $limit['attempts'])->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('2fa.enable', function (Request $request) { + $method = $request->route('method'); + $limit = app(\App\Services\TwoFactor\TwoFactorService::class)->getExtension($method)?->getRateLimit('enable') + ?? ['attempts' => 3, 'minutes' => 1]; + + return Limit::perMinutes($limit['minutes'], $limit['attempts'])->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('2fa.disable', function (Request $request) { + $method = $request->route('method'); + $limit = app(\App\Services\TwoFactor\TwoFactorService::class)->getExtension($method)?->getRateLimit('disable') + ?? ['attempts' => 3, 'minutes' => 1]; + + return Limit::perMinutes($limit['minutes'], $limit['attempts'])->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('2fa.action', function (Request $request) { + $method = $request->route('method'); + $limit = app(\App\Services\TwoFactor\TwoFactorService::class)->getExtension($method)?->getRateLimit('action') + ?? ['attempts' => 3, 'minutes' => 5]; + + return Limit::perMinutes($limit['minutes'], $limit['attempts'])->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/app/Services/TwoFactor/TwoFactorService.php b/app/Services/TwoFactor/TwoFactorService.php new file mode 100644 index 000000000..03c818505 --- /dev/null +++ b/app/Services/TwoFactor/TwoFactorService.php @@ -0,0 +1,148 @@ +extensions === null) { + $this->extensions = collect(); + $classes = ExtensionHelper::getAllExtensionClassesByNamespace('TwoFactor'); + + foreach ($classes as $class) { + if (is_string($class) && class_exists($class) && is_subclass_of($class, TwoFactorExtension::class)) { + /** @var TwoFactorExtension $extension */ + $extension = app($class); + $this->extensions->put($extension->getSettings('name'), $extension); + } + } + } + + return $this->extensions; + } + + /** + * Get a specific 2FA extension by its name. + */ + public function getExtension(string $name): ?TwoFactorExtension + { + return $this->getExtensions()->get($name); + } + + /** + * Get all enabled 2FA methods for the user. + */ + public function enabledMethods(User $user): Collection + { + return $user->twoFactorMethods() + ->where('is_enabled', true) + ->get(); + } + + /** + * Check if a specific 2FA method is enabled for the user. + */ + public function isMethodEnabled(User $user, string $method): bool + { + return $user->twoFactorMethods() + ->where('method', $method) + ->where('is_enabled', true) + ->exists(); + } + + /** + * Get all available 2FA methods for the user (including those not yet enabled). + */ + public function getAvailableMethodsForUser(User $user): Collection + { + return $this->getExtensions()->filter(fn (TwoFactorExtension $ext) => $ext->isAvailable($user)); + } + + /** + * Mark the user as 2FA verified for the current session. + */ + public function markVerified(Request $request, User $user): void + { + if ($request->hasSession()) { + $request->session()->put('two_factor_verified', true); + } + + // If remember_web cookie is present, store verification token in DB + $cookieName = Auth::getRecallerName(); + if ($request->hasCookie($cookieName)) { + TwoFactorVerifiedToken::updateOrCreate( + [ + 'user_id' => $user->id, + 'token_hash' => hash('sha256', $user->getRememberToken()), + ], + [ + 'verified_at' => now(), + 'expires_at' => now()->addMinutes((int) config('auth.two_factor_token_lifetime', 576000)), + ] + ); + } + } + + /** + * Check if the user is 2FA verified. + */ + public function isVerified(Request $request, User $user): bool + { + // 1. Session check - always authoritative for the current session + if ($request->hasSession() && $request->session()->get('two_factor_verified') === true) { + return true; + } + + // 2. DB token check - ONLY for remember-me re-authentication + if (Auth::viaRemember()) { + $rememberToken = $user->getRememberToken(); + if ($rememberToken) { + $tokenHash = hash('sha256', $rememberToken); + $token = TwoFactorVerifiedToken::where('user_id', $user->id) + ->where('token_hash', $tokenHash) + ->where('expires_at', '>', now()) + ->first(); + + if ($token) { + if ($request->hasSession()) { + $request->session()->put('two_factor_verified', true); + } + return true; + } + } + } + + return false; + } + + /** + * Clear 2FA verification state. + */ + public function clearVerified(Request $request, User $user): void + { + if ($request->hasSession()) { + $request->session()->forget('two_factor_verified'); + } + + $rememberToken = $user->getRememberToken(); + if ($rememberToken) { + TwoFactorVerifiedToken::where('user_id', $user->id) + ->where('token_hash', hash('sha256', $rememberToken)) + ->delete(); + } + } +} diff --git a/composer.json b/composer.json index b6f71dafb..57977057b 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "ext-curl": "*", "ext-intl": "*", "ext-mysqli": "*", + "bacon/bacon-qr-code": "^3.1", "doctrine/dbal": "^4.0.4", "guzzlehttp/guzzle": "^7.5", "hidehalo/nanoid-php": "^1.1.12", @@ -22,6 +23,7 @@ "laraveldaily/laravel-invoices": "^4.0.0", "league/flysystem-aws-s3-v3": "^3.28.0", "paypal/paypal-checkout-sdk": "^1.0.2", + "pragmarx/google2fa-laravel": "^3.0", "predis/predis": "*", "qirolab/laravel-themer": "^2.3.3", "socialiteproviders/discord": "^4.1.2", diff --git a/composer.lock b/composer.lock index db5ff076d..60f56c3fb 100644 --- a/composer.lock +++ b/composer.lock @@ -157,6 +157,61 @@ }, "time": "2026-04-29T18:07:13+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, { "name": "barryvdh/laravel-dompdf", "version": "v3.1.2", @@ -363,6 +418,56 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -4341,6 +4446,201 @@ }, "time": "2026-01-25T14:56:51+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, + { + "name": "pragmarx/google2fa-laravel", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-laravel.git", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c", + "shasum": "" + }, + "require": { + "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.0", + "pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*", + "phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12" + }, + "suggest": { + "bacon/bacon-qr-code": "Required to generate inline QR Codes.", + "pragmarx/recovery": "Generate recovery codes." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Google2FA": "PragmaRX\\Google2FALaravel\\Facade" + }, + "providers": [ + "PragmaRX\\Google2FALaravel\\ServiceProvider" + ] + }, + "component": "package", + "frameworks": [ + "Laravel" + ], + "branch-alias": { + "dev-master": "0.2-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FALaravel\\": "src/", + "PragmaRX\\Google2FALaravel\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "Authentication", + "Two Factor Authentication", + "google2fa", + "laravel" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-laravel/issues", + "source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1" + }, + "time": "2026-03-17T20:54:53+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0", + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpunit/phpunit": "~4|~5|~6|~7|~8|~9" + }, + "suggest": { + "bacon/bacon-qr-code": "For QR Code generation, requires imagick", + "chillerlan/php-qrcode": "For QR Code generation" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues", + "source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1" + }, + "time": "2025-09-19T23:02:26+00:00" + }, { "name": "predis/predis", "version": "v3.4.2", diff --git a/config/auth.php b/config/auth.php index 963c5ee73..f08079ebb 100644 --- a/config/auth.php +++ b/config/auth.php @@ -114,4 +114,5 @@ 'password_timeout' => 10800, + 'two_factor_token_lifetime' => 576000, // minutes (400 days) ]; diff --git a/config/google2fa.php b/config/google2fa.php new file mode 100644 index 000000000..2f4722107 --- /dev/null +++ b/config/google2fa.php @@ -0,0 +1,73 @@ + true, + + /* + * Lifetime in minutes. + * + * In case you need your users to be asked for a one time password every time they log in + * (with "remember me" disabled), you can set this to 0 (zero). + */ + 'lifetime' => 0, // Should be 0 since we handle verification persistence ourselves + + /* + * Keep alive. + * + * If this is true, every time a user access a page, the session lifetime is updated. + */ + 'keep_alive' => true, + + /* + * Auth Guard. + */ + 'auth_guard' => 'web', + + /* + * Session key. + */ + 'session_var' => 'google2fa', + + /* + * One Time Password Field Name. + */ + 'otp_input' => 'one_time_password', + + /* + * One Time Password Window. + */ + 'window' => 1, + + /* + * Forbid old passwords. + */ + 'forbid_old_passwords' => true, + + /* + * User's table column for google2fa secret. + * Note: This column is NOT used by CtrlPanel.gg. We handle verification manually via + * Google2FA::verifyKey() using the 'totp_secret' column in the user_two_factor_methods table. + * The package's automatic middleware/auto-detection is not used. + */ + 'otp_secret_column' => 'google2fa_secret', + + /* + * Guard route name. + */ + 'guard_route' => 'login.2fa.totp', + + /* + * QR Code Image Backend. + */ + 'qr_image_backend' => \PragmaRX\Google2FALaravel\Support\Constants::QRCODE_IMAGE_BACKEND_SVG, + + /* + * Secret Length. + */ + 'secret_length' => 32, + +]; diff --git a/database/migrations/2026_05_04_203516_create_two_factor_core_tables.php b/database/migrations/2026_05_04_203516_create_two_factor_core_tables.php new file mode 100644 index 000000000..341ff3c17 --- /dev/null +++ b/database/migrations/2026_05_04_203516_create_two_factor_core_tables.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('method'); + $table->boolean('is_enabled')->default(false); + $table->timestamps(); + + $table->unique(['user_id', 'method']); + }); + + Schema::create('two_factor_verified_tokens', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('token_hash', 100); + $table->timestamp('verified_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'token_hash']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('two_factor_verified_tokens'); + Schema::dropIfExists('user_two_factor_methods'); + } +}; diff --git a/routes/web.php b/routes/web.php index 6b9f97f06..075d19f4e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,10 @@ name('terms'); -Route::middleware(['auth', 'checkSuspended'])->group(function () { +Route::middleware(['auth', 'checkSuspended', 'two_factor.required'])->group(function () { //resend verification email Route::get('/email/verification-notification', function (Request $request) { $request->user()->sendEmailVerificationNotification(); @@ -61,6 +61,27 @@ return back()->with('success', 'Verification link sent!'); })->middleware(['auth', 'throttle:3,1'])->name('verification.send'); + //2fa challenge + Route::middleware(['auth', 'two_factor.required']) + ->prefix('login/2fa') + ->name('login.2fa.') + ->group(function () { + Route::get('/', [TwoFactorController::class, 'showChallenge'])->name('challenge'); + Route::get('/{method}', [TwoFactorExtensionController::class, 'showChallenge'])->name('method')->where('method', '[a-z_]+'); + Route::post('/{method}', [TwoFactorExtensionController::class, 'verify'])->name('verify')->middleware('throttle:2fa.verify')->where('method', '[a-z_]+'); + }); + + //2fa settings + Route::middleware(['auth', 'two_factor.verified']) + ->prefix('profile/security/2fa') + ->name('profile.2fa.') + ->group(function () { + Route::post('/{method}/setup', [TwoFactorExtensionController::class, 'setup'])->name('setup')->middleware('throttle:2fa.setup')->where('method', '[a-z_]+'); + Route::post('/{method}/enable', [TwoFactorExtensionController::class, 'enable'])->name('enable')->middleware('throttle:2fa.enable')->where('method', '[a-z_]+'); + Route::post('/{method}/disable', [TwoFactorExtensionController::class, 'disable'])->name('disable')->middleware('throttle:2fa.disable')->where('method', '[a-z_]+'); + Route::post('/{method}/{action}', [TwoFactorExtensionController::class, 'action'])->name('action')->middleware('throttle:2fa.action')->where(['method' => '[a-z_]+', 'action' => '[a-zA-Z_]+']); + }); + //normal routes Route::get('notifications/readAll', [NotificationController::class, 'readAll'])->name('notifications.readAll'); Route::resource('notifications', NotificationController::class); diff --git a/themes/BlueInfinity/views/layouts/app.blade.php b/themes/BlueInfinity/views/layouts/app.blade.php index cf129581f..f7a4e9d40 100644 --- a/themes/BlueInfinity/views/layouts/app.blade.php +++ b/themes/BlueInfinity/views/layouts/app.blade.php @@ -14,7 +14,7 @@ - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'CtrlPanel.gg') }} diff --git a/themes/BlueInfinity/views/layouts/main.blade.php b/themes/BlueInfinity/views/layouts/main.blade.php index b67fad1a1..ee3e70cfa 100644 --- a/themes/BlueInfinity/views/layouts/main.blade.php +++ b/themes/BlueInfinity/views/layouts/main.blade.php @@ -16,7 +16,7 @@ exists('logo.png') ? asset('storage/logo.png') : asset('images/ctrlpanel_logo.png') }}' property="og:image"> - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'CtrlPanel.gg') }} @@ -166,7 +166,7 @@ class="{{ $link->icon }}"> {{ $link->title }} {{ config('app.name', 'Laravel') }} Logo {{ config('app.name', 'CtrlPanel.gg') }} diff --git a/themes/default/views/admin/users/index.blade.php b/themes/default/views/admin/users/index.blade.php index 29d8f7171..70db0cd6d 100644 --- a/themes/default/views/admin/users/index.blade.php +++ b/themes/default/views/admin/users/index.blade.php @@ -50,6 +50,7 @@ class="mr-1 fas fa-paper-plane">{{ __('Notify') }} {{__('Servers')}} {{__('Referrals')}} {{__('Verified')}} + {{__('2FA')}} {{__('Last seen')}} @@ -129,6 +130,10 @@ function submitResult() { data: 'verified', sortable: false }, + { + data: 'two_factor', + sortable: false + }, { data: 'last_seen', }, diff --git a/themes/default/views/admin/users/show.blade.php b/themes/default/views/admin/users/show.blade.php index 2b6d3f0e9..f91adc8c3 100644 --- a/themes/default/views/admin/users/show.blade.php +++ b/themes/default/views/admin/users/show.blade.php @@ -183,7 +183,7 @@ class='badge'>{{ $role->name }}
- {{ $user->ip }} + {{ is_null($user->ip) ? __('N/A') : $user->ip }}
@@ -208,7 +208,7 @@ class="mr-2 fas fa-coins">{{ Currency::formatForDisplay($user->creditUsage()
- {{ $user->referredBy() != null ? $user->referredBy()->name : 'None' }} + {{ is_null($user->referredBy()) ? __('None') : $user->referredBy()->name }}
@@ -226,9 +226,6 @@ class="mr-2 fas fa-coins">{{ Currency::formatForDisplay($user->creditUsage() -
-
-
@@ -246,6 +243,19 @@ class="mr-2 fas fa-coins">{{ Currency::formatForDisplay($user->creditUsage()
+
+
+
+ +
+
+ + {{ empty($enabled2faMethods) ? __('None') : $enabled2faMethods }} + +
+
+
+
diff --git a/themes/default/views/auth/login.blade.php b/themes/default/views/auth/login.blade.php index fc8c1535b..731d0de4f 100644 --- a/themes/default/views/auth/login.blade.php +++ b/themes/default/views/auth/login.blade.php @@ -8,7 +8,7 @@
{{ config('app.name', 'Laravel') }} + class="mr-1">{{ config('app.name', 'CtrlPanel.gg') }} @if ($website_settings->enable_login_logo)
{{ config('app.name', 'Laravel') }} + class="mr-1">{{ config('app.name', 'CtrlPanel.gg') }}
@if (session('status')) diff --git a/themes/default/views/auth/passwords/reset.blade.php b/themes/default/views/auth/passwords/reset.blade.php index b76f90867..acf2b6495 100644 --- a/themes/default/views/auth/passwords/reset.blade.php +++ b/themes/default/views/auth/passwords/reset.blade.php @@ -7,7 +7,7 @@
{{ config('app.name', 'Laravel') }} + class="mr-1">{{ config('app.name', 'CtrlPanel.gg') }}
{{ config('app.name', 'Laravel') }} + class="mr-1">{{ config('app.name', 'CtrlPanel.gg') }}
@if (!app(App\Settings\UserSettings::class)->creation_enabled) diff --git a/themes/default/views/auth/two-factor/picker.blade.php b/themes/default/views/auth/two-factor/picker.blade.php new file mode 100644 index 000000000..3a84d6991 --- /dev/null +++ b/themes/default/views/auth/two-factor/picker.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.app') + +@section('content') +@php($suppressSweetAlert2 = true) + + + + +@endsection diff --git a/themes/default/views/layouts/app.blade.php b/themes/default/views/layouts/app.blade.php index 7ee3d5b2b..7280c76ad 100644 --- a/themes/default/views/layouts/app.blade.php +++ b/themes/default/views/layouts/app.blade.php @@ -14,7 +14,7 @@ - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'CtrlPanel.gg') }} @@ -34,34 +34,38 @@ @vite('themes/default/sass/app.scss') @yield('content') +@stack('modals') +@stack('scripts') diff --git a/themes/default/views/layouts/errors.blade.php b/themes/default/views/layouts/errors.blade.php index 0858bfdbd..fac1ed064 100644 --- a/themes/default/views/layouts/errors.blade.php +++ b/themes/default/views/layouts/errors.blade.php @@ -13,7 +13,7 @@ exists('logo.png') ? asset('storage/logo.png') : asset('images/ctrlpanel_logo.png') }}' property="og:image"> - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'CtrlPanel.gg') }} diff --git a/themes/default/views/layouts/main.blade.php b/themes/default/views/layouts/main.blade.php index bd404b565..58cbf099d 100644 --- a/themes/default/views/layouts/main.blade.php +++ b/themes/default/views/layouts/main.blade.php @@ -16,7 +16,7 @@ exists('logo.png') ? asset('storage/logo.png') : asset('images/ctrlpanel_logo.png') }}' property="og:image"> - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'CtrlPanel.gg') }} @@ -170,7 +170,7 @@ class="{{ $link->icon }}"> {{ $link->title }} {{ config('app.name', 'Laravel') }} Logo {{ config('app.name', 'CtrlPanel.gg') }} @@ -456,6 +456,7 @@ class="nav-link @if (Request::routeIs('admin.activitylogs.*')) active @endif"> @yield('content') @include('modals.redeem_voucher_modal') + @stack('modals')