Skip to content

2FA: require password when disabling #106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: feature/2fa
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/Http/Controllers/Auth/ConfirmablePasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Support\Facades\Date;

class ConfirmablePasswordController extends Controller
{
Expand Down Expand Up @@ -38,4 +39,23 @@ public function store(Request $request): RedirectResponse

return redirect()->intended(route('dashboard', absolute: false));
}

public function status(Request $request)
{
$lastConfirmation = $request->session()->get(
'auth.password_confirmed_at', 0
);

$lastConfirmed = (Date::now()->unix() - $lastConfirmation);

$confirmed = $lastConfirmed < $request->input(
'seconds', config('auth.password_timeout', 900)
);

return response()->json([
'confirmed' => $confirmed,
], headers: array_filter([
'X-Retry-After' => $confirmed ? $lastConfirmed : null,
]));
}
}
113 changes: 113 additions & 0 deletions resources/js/components/confirms-password.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import axios from 'axios';
import { Lock } from 'lucide-react';
import { PropsWithChildren, useRef, useState } from 'react';
import InputError from './input-error';
import { Button } from './ui/button';
import { Input } from './ui/input';

interface Props {
title?: string;
content?: string;
button?: string;
onConfirm(): void;
}

export function ConfirmsPassword({
title = 'Confirm Password',
content = 'For your security, please confirm your password to continue.',
button = 'Confirm',
onConfirm,
children,
}: PropsWithChildren<Props>) {
const [confirmingPassword, setConfirmingPassword] = useState(false);
const [form, setForm] = useState({
password: '',
error: '',
processing: false,
});
const passwordRef = useRef<HTMLInputElement>(null);

function startConfirmingPassword() {
axios.get(route('password.confirmation')).then((response) => {
if (response.data.confirmed) {
onConfirm();
} else {
setConfirmingPassword(true);

setTimeout(() => passwordRef.current?.focus(), 250);
}
});
}

function confirmPassword() {
setForm({ ...form, processing: true });

axios
.post(route('password.confirm'), {
password: form.password,
})
.then(() => {
closeModal();
setTimeout(() => onConfirm(), 250);
})
.catch((error) => {
setForm({
...form,
processing: false,
error: error.response.data.errors.password[0],
});
passwordRef.current?.focus();
});
}

function closeModal() {
setConfirmingPassword(false);
setForm({ processing: false, password: '', error: '' });
}

return (
<>
<Dialog open={confirmingPassword} onOpenChange={setConfirmingPassword}>
<DialogTrigger asChild>
<span onClick={startConfirmingPassword}>{children}</span>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="flex items-center justify-center">
<div className="mb-3 w-auto rounded-full border border-stone-100 bg-white p-0.5 shadow-sm dark:border-stone-600 dark:bg-stone-800">
<div className="relative overflow-hidden rounded-full border border-stone-200 bg-stone-100 p-2.5 dark:border-stone-600 dark:bg-stone-200">
<Lock className="relative z-20 size-5 dark:text-black" />
</div>
</div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="text-center">{content}</DialogDescription>
</DialogHeader>

<div className="relative mb-6 flex w-full flex-col items-center justify-center space-y-5">
<div className="mt-4">
<Input
ref={passwordRef}
type="password"
className="mt-1 block w-full"
placeholder="Password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.currentTarget.value })}
/>
<InputError message={form.error} className="mt-2" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeModal}>
Cancel
</Button>

<Button className={cn('ml-2', { 'opacity-25': form.processing })} onClick={confirmPassword} disabled={form.processing}>
{button}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
11 changes: 8 additions & 3 deletions resources/js/pages/settings/two-factor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { Check, Copy, Eye, EyeOff, Loader, ScanLine, LockKeyhole } from 'lucide-react';
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
import type { BreadcrumbItem } from '@/types';
import { ConfirmsPassword } from '@/components/confirms-password';

interface TwoFactorProps {
confirmed: boolean;
Expand Down Expand Up @@ -294,9 +295,13 @@ export default function TwoFactor({ confirmed: initialConfirmed, recoveryCodes }
</div>

<div className="inline relative">
<Button variant="destructive" onClick={disable}>
Disable 2FA
</Button>
<ConfirmsPassword
onConfirm={disable}
>
<Button variant="destructive">
Disable 2FA
</Button>
</ConfirmsPassword>
</div>
</div>
)}
Expand Down
3 changes: 3 additions & 0 deletions routes/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');

Route::get('confirm-password/status', [ConfirmablePasswordController::class, 'status'])
->name('password.confirmation');

Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);

Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
Expand Down