trypost/app/Http/Controllers/App/Settings/AuthenticationController.php
Paulo Castellano 79f800f858 refactor: dedicated callback URL for connect-from-settings flow
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.

- routes/auth.php returns to its original state — redirect + callback
  both back inside the `guest` group.
- routes/app.php gains a paired callback route at
  /settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
  connectProviderCallback override Socialite's redirectUrl so the
  round-trip stays on the connect-flow URL. The Auth::check() branch
  in the auth controllers is gone.

The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
2026-05-04 19:30:16 -03:00

206 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\App\Settings;
use App\Http\Controllers\App\Controller;
use App\Http\Requests\App\Settings\AuthenticationPasswordRequest;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\AbstractProvider;
class AuthenticationController extends Controller
{
private const array PROVIDERS = ['google', 'github'];
public function edit(Request $request): Response
{
$user = $request->user();
return Inertia::render('settings/profile/Authentication', [
'sessions' => $this->getSessions($request),
'hasPassword' => (bool) $user->password,
'connectedAccounts' => $this->getConnectedAccounts($user),
]);
}
public function updatePassword(AuthenticationPasswordRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->password,
]);
return back()->with('flash.success', __('settings.flash.password_updated'));
}
public function destroyOtherSessions(Request $request): RedirectResponse
{
$user = $request->user();
if ($user->password) {
$request->validate([
'password' => ['required', 'string', 'current_password'],
]);
} else {
$request->validate([
'email_confirmation' => ['required', 'string', Rule::in([$user->email])],
], [
'email_confirmation.in' => __('settings.authentication.sessions.email_mismatch'),
]);
}
DB::table(config('session.table', 'sessions'))
->where('user_id', $user->id)
->where('id', '!=', $request->session()->getId())
->delete();
return back()->with('flash.success', __('settings.authentication.sessions.flash_logged_out'));
}
public function connectProvider(string $provider): RedirectResponse
{
abort_unless(in_array($provider, self::PROVIDERS, true), 404);
return $this->driver($provider)->redirect();
}
public function connectProviderCallback(Request $request, string $provider): RedirectResponse
{
abort_unless(in_array($provider, self::PROVIDERS, true), 404);
try {
$providerUser = $this->driver($provider)->user();
} catch (\Exception) {
return redirect()->route('app.authentication.edit')
->with('flash.error', __('settings.authentication.providers.flash_already_linked', ['provider' => ucfirst($provider)]));
}
$user = $request->user();
$column = "{$provider}_id";
$providerId = (string) $providerUser->getId();
$existing = User::where($column, $providerId)
->where('id', '!=', $user->id)
->first();
if ($existing) {
return redirect()->route('app.authentication.edit')
->with('flash.error', __('settings.authentication.providers.flash_already_linked', ['provider' => ucfirst($provider)]));
}
if ($user->{$column} !== $providerId) {
$user->update([$column => $providerId]);
}
return redirect()->route('app.authentication.edit')
->with('flash.success', __('settings.authentication.providers.flash_connected', ['provider' => ucfirst($provider)]));
}
/**
* Build a Socialite driver for the connect flow with its dedicated
* callback URL. The signup/login flow uses the driver's default
* redirect URL configured in `config/services.php`; here we override
* so each flow round-trips through its own route.
*/
private function driver(string $provider): AbstractProvider
{
$callback = route('app.authentication.connect-provider.callback', $provider);
return match ($provider) {
'google' => Socialite::driver('google-auth')->redirectUrl($callback),
'github' => Socialite::driver('github')->scopes(['read:user', 'user:email'])->redirectUrl($callback),
};
}
public function disconnectProvider(Request $request, string $provider): RedirectResponse
{
abort_unless(in_array($provider, self::PROVIDERS, true), 404);
$user = $request->user();
$column = "{$provider}_id";
if (! $user->{$column}) {
return back();
}
if (! $this->canDisconnect($user, $provider)) {
return back()->with('flash.error', __('settings.authentication.providers.flash_cannot_disconnect'));
}
$user->update([$column => null]);
return back()->with('flash.success', __('settings.authentication.providers.flash_disconnected', [
'provider' => ucfirst($provider),
]));
}
/**
* @return array<int, array{id: string, ip_address: string|null, user_agent: string|null, last_active: string, is_current: bool}>
*/
private function getSessions(Request $request): array
{
if (config('session.driver') !== 'database') {
return [];
}
return collect(
DB::table(config('session.table', 'sessions'))
->where('user_id', $request->user()->id)
->orderByDesc('last_activity')
->get()
)->map(fn ($session) => [
'id' => $session->id,
'ip_address' => $session->ip_address,
'user_agent' => $session->user_agent,
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
'is_current' => $session->id === $request->session()->getId(),
])->values()->all();
}
/**
* @return array<int, array{provider: string, label: string, connected: bool, can_disconnect: bool}>
*/
private function getConnectedAccounts(User $user): array
{
$labels = [
'google' => 'Google',
'github' => 'GitHub',
];
return collect(self::PROVIDERS)->map(fn (string $provider) => [
'provider' => $provider,
'label' => $labels[$provider],
'connected' => (bool) $user->{"{$provider}_id"},
'can_disconnect' => $user->{"{$provider}_id"} && $this->canDisconnect($user, $provider),
])->values()->all();
}
private function canDisconnect(User $user, string $provider): bool
{
$remainingMethods = 0;
if ($user->password) {
$remainingMethods++;
}
foreach (self::PROVIDERS as $other) {
if ($other === $provider) {
continue;
}
if ($user->{"{$other}_id"}) {
$remainingMethods++;
}
}
return $remainingMethods > 0;
}
}