php 63 lines · 10 steps

Two-factor auth challenge flow in Laravel

How a controller and middleware cooperate to gate login behind a TOTP code stored in the session.

Explained by highlit
1class TwoFactorController extends Controller
2{
3 public function show(Request $request): View|RedirectResponse
4 {
5 if (! $request->session()->has('2fa:user:id')) {
6 return redirect()->route('login');
7 }
8 
9 return view('auth.two-factor');
10 }
11 
12 public function store(Request $request): RedirectResponse
13 {
14 $data = $request->validate([
15 'code' => ['required', 'string'],
16 ]);
17 
18 $userId = $request->session()->get('2fa:user:id');
19 
20 abort_unless($userId, 419);
21 
22 $user = User::findOrFail($userId);
23 
24 if (! $this->google2fa->verifyKey($user->two_factor_secret, $data['code'])) {
25 RateLimiter::hit($this->throttleKey($request));
26 
27 throw ValidationException::withMessages([
28 'code' => __('The provided two-factor code is invalid.'),
29 ]);
30 }
31 
32 $request->session()->forget('2fa:user:id');
33 $request->session()->put('2fa:passed_at', now());
34 $request->session()->regenerate();
35 
36 Auth::loginUsingId($user->id, $request->session()->pull('2fa:remember', false));
37 
38 return redirect()->intended(route('dashboard'));
39 }
40 
41 protected function throttleKey(Request $request): string
42 {
43 return 'two-factor:'.$request->ip();
44 }
45}
46 
47class EnsureTwoFactorIsConfirmed
48{
49 public function handle(Request $request, Closure $next): Response
50 {
51 $user = $request->user();
52 
53 if ($user?->two_factor_secret && ! $request->session()->has('2fa:passed_at')) {
54 $request->session()->put('2fa:user:id', $user->id);
55 
56 Auth::logout();
57 
58 return redirect()->route('two-factor.challenge');
59 }
60 
61 return $next($request);
62 }
63}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1A pending user id in the session is what links the challenge screen, verification, and the middleware into one flow.
  2. 2Regenerating the session and only calling loginUsingId after a valid code prevents fixation and premature authentication.
  3. 3Rate-limiting verification attempts by IP blunts brute-forcing of the short numeric code.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Two-factor auth challenge flow in Laravel — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code