Files
Andras Bacsai 8521f973dd fix(auth): show specific error messages for denied OAuth logins
Add OauthLoginException, which carries a translation key for the message
shown on the login page. The exception message only goes to the log.
OauthLoginService now throws it for an unverified email, an account that
is already linked to another identity, and disabled registration.
OauthController shows the matching message for each case. Other
HttpExceptions fall back to a generic OAuth failure message.

The email verification check now runs before the linked-identity check,
so an unverified email cannot reveal that an account is already linked.

Add translations for the new auth.failed.oauth* keys in all locales and
cover the new error messages with feature tests.
2026-10-02 16:07:48 +02:00

76 lines
2.7 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Exceptions\OauthLoginException;
use App\Models\OauthSetting;
use App\Services\Auth\OauthLoginService;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public function redirect(string $provider)
{
$oauthSetting = $this->enabledProvider($provider);
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
return $socialiteProvider->redirect();
}
public function callback(string $provider, OauthLoginService $oauthLoginService)
{
try {
$oauthSetting = $this->enabledProvider($provider);
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
$user = $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
if ($oauthLoginService->requiresTwoFactorChallenge($user)) {
return redirect()->route('two-factor.login');
}
return redirect('/');
} catch (\Exception $e) {
$this->logCallbackFailure($provider, $e);
$errorCode = match (true) {
$e instanceof OauthLoginException => $e->userMessageKey,
$e instanceof HttpException => 'auth.failed.oauth',
default => 'auth.failed.callback',
};
return redirect()->route('login')->withErrors([__($errorCode)]);
}
}
private function logCallbackFailure(string $provider, \Throwable $exception): void
{
auditLog('auth.oauth.callback_failed', [
'provider' => $provider,
'exception_class' => $exception::class,
'reason' => $exception instanceof HttpException ? 'access_denied' : 'callback_error',
], 'warning');
Log::error('OAuth callback failed.', [
'provider' => $provider,
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'request_error' => request()->query('error'),
'request_error_description' => request()->query('error_description'),
'has_code' => request()->query->has('code'),
'has_state' => request()->query->has('state'),
'ip' => request()->ip(),
'exception' => $exception,
]);
}
private function enabledProvider(string $provider): OauthSetting
{
$oauthSetting = OauthSetting::where('provider', $provider)->first();
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
throw new HttpException(403, 'OAuth provider is not enabled');
}
return $oauthSetting;
}
}