diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 6a3e2091..f04b7003 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -49,6 +49,18 @@ public function store(Request $request): RedirectResponse RateLimiter::clear($this->throttleKey()); Session::regenerate(); + if (user()->two_factor_secret) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + $request->session()->put([ + 'login.id' => user()->id, + 'login.remember' => $request->boolean('remember'), + ]); + + return redirect()->route('two-factor.login'); + } + return redirect()->intended(route('servers', absolute: false)); } diff --git a/app/Http/Controllers/Auth/TwoFactorAuthenticatedSessionController.php b/app/Http/Controllers/Auth/TwoFactorAuthenticatedSessionController.php new file mode 100644 index 00000000..6e9ccc06 --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorAuthenticatedSessionController.php @@ -0,0 +1,63 @@ +guard = $guard; + } + + #[Get('two-factor', name: 'two-factor.login')] + public function create(TwoFactorLoginRequest $request): \Inertia\Response + { + if (! $request->hasChallengedUser()) { + throw new HttpResponseException(redirect()->route('login')); + } + + return Inertia::render('auth/two-factor'); + } + + #[Post('two-factor', name: 'two-factor.store')] + public function store(TwoFactorLoginRequest $request): TwoFactorLoginResponse|Response + { + /** @var User $user */ + $user = $request->challengedUser(); + + if ($code = $request->validRecoveryCode()) { + $user->replaceRecoveryCode($code); + + event(new RecoveryCodeReplaced($user, $code)); + } elseif (! $request->hasValidCode()) { + event(new TwoFactorAuthenticationFailed($user)); + + return app(FailedTwoFactorLoginResponse::class)->toResponse($request); + } + + event(new ValidTwoFactorAuthenticationCodeProvided($user)); + + $this->guard->login($user, $request->remember()); + + $request->session()->regenerate(); + + return redirect()->intended(route('servers', absolute: false)); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 15f9c260..5cf35ebd 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -11,9 +11,12 @@ use Illuminate\Validation\Rules\Password; use Inertia\Inertia; use Inertia\Response; +use Laravel\Fortify\Actions\DisableTwoFactorAuthentication; +use Laravel\Fortify\Actions\EnableTwoFactorAuthentication; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Patch; +use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; use Spatie\RouteAttributes\Attributes\Put; @@ -65,4 +68,30 @@ public function password(Request $request): RedirectResponse return to_route('profile'); } + + #[Post('/enable-two-factor', name: 'profile.enable-two-factor')] + public function enableTwoFactor(): RedirectResponse + { + $user = user(); + + app(EnableTwoFactorAuthentication::class)($user); + + return back() + ->with('success', 'Two factor authentication enabled.') + ->with('data', [ + 'qr_code' => $user->twoFactorQrCodeSvg(), + 'qr_code_url' => $user->twoFactorQrCodeUrl(), + 'recovery_codes' => $user->recoveryCodes(), + ]); + } + + #[Post('/disable-two-factor', name: 'profile.disable-two-factor')] + public function disableTwoFactor(): RedirectResponse + { + $user = user(); + + app(DisableTwoFactorAuthentication::class)($user); + + return back()->with('success', 'Two factor authentication disabled.'); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c8407338..779ee800 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,8 +2,10 @@ namespace App\Http\Middleware; +use App\Http\Resources\ProjectResource; use App\Http\Resources\ServerResource; use App\Http\Resources\SiteResource; +use App\Http\Resources\UserResource; use App\Models\Server; use App\Models\Site; use App\Models\User; @@ -84,11 +86,11 @@ public function share(Request $request): array 'version' => config('app.version'), 'demo' => config('app.demo'), 'quote' => ['message' => trim($message), 'author' => trim($author)], - 'auth' => [ - 'user' => $user, - 'projects' => $user?->allProjects()->get(), - 'currentProject' => $user?->currentProject, - ], + 'auth' => $user ? [ + 'user' => UserResource::make($user->load('projects')), + 'projects' => ProjectResource::collection($user->allProjects()->get()), + 'currentProject' => ProjectResource::make($user->currentProject), + ] : null, 'public_key_text' => __('servers.create.public_key_text', ['public_key' => get_public_key_content()]), 'project_servers' => $servers, 'configs' => [ diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 84ca78ba..b789bd53 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -19,6 +19,7 @@ public function toArray(Request $request): array 'name' => $this->name, 'email' => $this->email, 'role' => $this->role, + 'two_factor_enabled' => (bool) $this->two_factor_secret, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, 'projects' => ProjectResource::collection($this->whenLoaded('projects')), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0c3f21c3..3bea05e7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,6 +34,9 @@ public function boot(): void $this->app->bind('plugins', fn (): Plugins => new Plugins); Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); + Fortify::twoFactorChallengeView(function () { + return view('app'); + }); if (config('app.force_https')) { URL::forceHttps(); diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index 60905bc2..0570c3b8 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -1,7 +1,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { AppHeader } from '@/components/app-header'; import { type BreadcrumbItem, NavItem, SharedData } from '@/types'; -import { type PropsWithChildren } from 'react'; +import { type PropsWithChildren, useEffect } from 'react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { usePage } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; @@ -20,38 +20,40 @@ export default function Layout({ }>) { const page = usePage(); - if (page.props.flash && page.props.flash.success) { - toast( -
- - {page.props.flash.success} -
, - ); - } - if (page.props.flash && page.props.flash.error) { - toast( -
- - {page.props.flash.error} -
, - ); - } - if (page.props.flash && page.props.flash.warning) { - toast( -
- - {page.props.flash.warning} -
, - ); - } - if (page.props.flash && page.props.flash.info) { - toast( -
- - {page.props.flash.info} -
, - ); - } + useEffect(() => { + if (page.props.flash && page.props.flash.success) { + toast( +
+ + {page.props.flash.success} +
, + ); + } + if (page.props.flash && page.props.flash.error) { + toast( +
+ + {page.props.flash.error} +
, + ); + } + if (page.props.flash && page.props.flash.warning) { + toast( +
+ + {page.props.flash.warning} +
, + ); + } + if (page.props.flash && page.props.flash.info) { + toast( +
+ + {page.props.flash.info} +
, + ); + } + }, [page.props.flash]); const queryClient = new QueryClient(); diff --git a/resources/js/pages/auth/two-factor.tsx b/resources/js/pages/auth/two-factor.tsx new file mode 100644 index 00000000..3350c0d8 --- /dev/null +++ b/resources/js/pages/auth/two-factor.tsx @@ -0,0 +1,76 @@ +import { Head, Link, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/ui/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth/layout'; +import { Form, FormField, FormFields } from '@/components/ui/form'; + +export default function TwoFactor() { + const form = useForm>({ + code: '', + recovery_code: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + form.post(route('two-factor.store'), { + onFinish: () => form.reset(), + }); + }; + + return ( + + + +
+ + + + form.setData('code', e.target.value)} + /> + + + + + + + form.setData('recovery_code', e.target.value)} + /> + + + + +
+ + +
+
+
+
+ ); +} diff --git a/resources/js/pages/profile/components/two-factor.tsx b/resources/js/pages/profile/components/two-factor.tsx new file mode 100644 index 00000000..c9f0bc6b --- /dev/null +++ b/resources/js/pages/profile/components/two-factor.tsx @@ -0,0 +1,139 @@ +import { useForm, usePage } from '@inertiajs/react'; +import type { SharedData } from '@/types'; +import { FormEventHandler, ReactNode, useState } from 'react'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { CheckCircle2Icon, LoaderCircleIcon, XCircleIcon } from 'lucide-react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { FormField, FormFields } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +function Disable(): ReactNode { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.post(route('profile.disable-two-factor'), { + preserveScroll: true, + onSuccess: () => setOpen(false), + }); + }; + + return ( + + + + + + + Disable two factor + Disable two factor + +

Are you sure you want to enable two factor authentication?

+ + + + + + +
+
+ ); +} + +function Enable() { + const form = useForm(); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('profile.enable-two-factor')); + }; + + return ( + + ); +} + +export default function TwoFactor() { + const page = usePage< + SharedData & { + flash: { + data?: { + qr_code?: string; + qr_code_url?: string; + recovery_codes?: string[]; + }; + }; + } + >(); + + return ( + + + Two factor authentication + Enable or Disable two factor authentication + + + {page.props.flash.data?.qr_code && ( + + + +
+
+
+
+ + + + + + +