Files
crewli/api/app/Http/Controllers/Api/V1/LoginController.php
bert.hausmans f1a8591d17 fix: critical MFA bypass — old auth tokens survive MFA challenge
SECURITY: A user with MFA enabled could bypass the MFA challenge by
using a pre-existing auth cookie from a previous session.

Vulnerability chain:
1. Auth::attempt() in LoginController created a Laravel session
   (unnecessary side effect — only credential validation was needed)
2. When MFA was required, the response did NOT revoke existing
   Sanctum tokens or expire the auth cookie
3. If the MFA session expired, the user could navigate directly to
   any page and the old auth cookie would authenticate them

Fixes:
- Replace Auth::attempt() with Hash::check() — no session created
- Revoke ALL existing Sanctum tokens when MFA is required, so old
  sessions cannot bypass the challenge
- Expire the auth cookie in the MFA-required response via
  forgetAuthCookie(), ensuring the browser discards stale tokens
- Auth is now ONLY issued after successful MFA verification in
  MfaVerifyController

New security tests (11 added):
- MFA login returns no auth token or user data
- MFA login expires the auth cookie
- MFA login revokes all existing tokens
- Old token returns 401 after MFA login
- MFA session token cannot be used as Bearer token
- MFA session consumed after successful verify (no replay)
- MFA session survives failed verify (user can retry)
- Auth cookie only issued on successful MFA verify
- MFA session expires after TTL (10 minutes)
- Email codes consumed after use (no replay)
- Trusted device expires after 30 days

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:49:51 +02:00

112 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\MfaMethod;
use App\Http\Controllers\Api\V1\Traits\SetAuthCookie;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\LoginRequest;
use App\Http\Resources\Api\V1\MeResource;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
final class LoginController extends Controller
{
use SetAuthCookie;
public function __construct(
private MfaService $mfaService,
) {}
public function __invoke(LoginRequest $request): JsonResponse
{
// Validate credentials WITHOUT creating a session.
// Auth::attempt() must NOT be used here — it establishes a Laravel
// session, which could grant access before MFA verification.
$user = User::where('email', $request->validated('email'))->first();
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
Log::warning('Failed login attempt', [
'email' => $request->validated('email'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return $this->unauthorized('Invalid credentials');
}
// MFA enabled and confirmed — check trusted device or require MFA
if ($user->mfa_enabled && $user->mfa_confirmed_at) {
$fingerprint = $request->header('X-Device-Fingerprint');
if ($fingerprint && $this->mfaService->isDeviceTrusted($user, $fingerprint)) {
return $this->issueToken($user, $request);
}
// Revoke ALL existing tokens so old sessions cannot bypass MFA.
// The only way to get a new token is through MfaVerifyController
// after a successful code verification.
$user->tokens()->delete();
$mfaSession = $this->mfaService->createMfaSession($user, $request->ip());
// Auto-send email code if email is the preferred method
if ($user->mfa_method === MfaMethod::EMAIL->value) {
try {
$this->mfaService->sendEmailCode($user);
} catch (\DomainException) {
// Rate limited — code was already sent recently
}
}
// Return MFA challenge — NO auth token, NO auth cookie.
// Expire the auth cookie to invalidate any stale browser session.
$cookieName = $this->resolveCookieName($request);
return response()->json([
'success' => true,
'mfa_required' => true,
...$mfaSession,
])->withCookie($this->forgetAuthCookie($cookieName));
}
// MFA required by policy but not yet set up — issue token with flag
if ($this->mfaService->isMfaRequired($user) && ! $user->mfa_enabled) {
$response = $this->issueToken($user, $request);
$data = $response->getData(true);
$data['mfa_setup_required'] = true;
$cookieName = $this->resolveCookieName($request);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json($data)
->withCookie($this->makeAuthCookie($cookieName, $token));
}
// No MFA — issue token as normal
return $this->issueToken($user, $request);
}
private function issueToken(User $user, LoginRequest $request): JsonResponse
{
$user->load([
'organisations',
'roles',
'permissions',
'persons' => fn ($q) => $q->with(['event:id,name,slug,start_date,end_date,organisation_id', 'event.organisation:id,name']),
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'Login successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
}
}