Files
projectsend/app/Modules/Identity/Http/Controllers/SocialLoginController.php
T
ignacionelson 495f3ae471 Let each role, and each person, choose where they land after signing in
A role now has a start page: the dashboard, files, upload, groups,
clients or the activity log (the last two for staff only). Anyone can
override their role's choice in their profile. The administrator role
takes a start page too, while everything else about it stays locked.

A choice is only used if the account can open that page now. Otherwise
the next one down is tried, ending at the dashboard, so a permission
removed later never lands somebody on a 403. A role cannot be saved
with a start page its own permissions block. A link followed before
signing in still wins, and a waiting getting-started or what's-new page
still goes first.

Applies to password, two-factor and provider sign-ins, and to the site
root for someone already signed in. StartPageTest opens every page for
real, with and without its permission, so the enum cannot drift from
the routes.

Requested by @Zodiac1978 in #1777.
2026-09-13 15:05:40 -03:00

191 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\SignIn;
use App\Modules\Identity\StartPages;
use App\Modules\Identity\Social\SocialAuthenticator;
use App\Modules\Identity\Social\SocialGateway;
use App\Modules\Identity\Social\SocialIdentity;
use App\Modules\Identity\Social\SocialProvider;
use App\Modules\Identity\Social\SocialSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Signing in through an identity provider, and connecting one to an
* account that already exists.
*
* Both start the same OAuth exchange and come back to the same callback,
* which is why the intent is written into the session before the redirect
* rather than inferred afterwards. The callback can be neither `guest`
* (connecting requires a session) nor `auth` (signing in must not have
* one), so the session marker is also what refuses a stray or replayed
* callback that nobody asked for.
*/
class SocialLoginController extends Controller
{
private const INTENT = 'social.intent';
private const PROVIDER = 'social.provider';
public function __construct(
private readonly SocialAuthenticator $authenticator,
private readonly SignIn $signIn,
private readonly ActivityLogger $activity,
private readonly StartPages $startPages,
) {}
/**
* Resolved per call rather than injected.
*
* Laravel memoises a controller instance on its Route object, so
* anything taken through the constructor is built once for the life
* of the process — and the gateway wraps a Socialite provider bound
* to the *current* request's `code`, `state` and session. Under
* PHP-FPM that is invisible; under Octane, or in a test that swaps
* the gateway twice, it is a stale object serving a live request.
*/
private function gateway(): SocialGateway
{
return app(SocialGateway::class);
}
/** Begin a sign-in. */
public function redirect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'login');
}
/** Begin connecting a provider to the signed-in account. */
public function connect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'link');
}
public function callback(Request $request, string $provider): RedirectResponse
{
$case = $this->provider($provider);
$intent = $request->session()->pull(self::INTENT);
$expected = $request->session()->pull(self::PROVIDER);
// Nobody started this exchange from here.
if (! is_string($intent) || $expected !== $case->value) {
return redirect()->route('login')->with('error', __('That sign-in could not be completed. Please try again.'));
}
$settings = SocialSettings::for($case);
if (! $settings->usable()) {
return redirect()->route('login')->with('error', __('That sign-in method is not available.'));
}
$identity = $this->gateway()->identity($settings);
if ($identity === null) {
return $intent === 'link'
? redirect()->route('connected-accounts.edit')->with('error', __('That sign-in could not be completed. Please try again.'))
: redirect()->route('login')->with('error', __('That sign-in could not be completed. Please try again.'));
}
if ($intent === 'link') {
return $this->completeLink($request, $settings, $identity);
}
$resolution = $this->authenticator->resolve($settings, $identity);
if ($resolution->user === null) {
return redirect()->route('login')->with('error', $resolution->refusal);
}
if ($resolution->linked && ! $resolution->provisioned) {
$this->activity->log(Action::SocialAccountLinked, $resolution->user, $resolution->user, [
'provider' => $case->label(),
]);
}
// The same account-state check a password login gets, with the
// same wording. An account awaiting approval must not be let in
// by a different door — including the one that just created it.
$refusal = $this->signIn->refusalReason($resolution->user);
if ($refusal !== null) {
return redirect()->route('login')->with('error', $refusal);
}
// Two-factor still applies, identically: a second factor that a
// provider could skip is not a second factor.
if ($this->signIn->begin($resolution->user, remember: false)) {
return redirect()->route('two-factor.challenge');
}
$request->session()->regenerate();
return redirect()->intended($this->startPages->pathFor($resolution->user));
}
private function begin(Request $request, string $provider, string $intent): Response
{
$case = $this->provider($provider);
$settings = SocialSettings::for($case);
if (! $settings->usable()) {
return redirect()->route($intent === 'link' ? 'connected-accounts.edit' : 'login')
->with('error', __('That sign-in method is not available.'));
}
$request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]);
// Inertia::location(), not the redirect itself. Connecting starts
// as an Inertia XHR from the settings screen, and an XHR follows a
// 302 to the provider cross-origin, where CORS kills it before the
// person ever leaves the page. The 409 + X-Inertia-Location pair
// makes the client navigate top-level instead; a plain browser
// request — the login flow — passes through unchanged.
return Inertia::location($this->gateway()->redirect($settings));
}
private function completeLink(Request $request, SocialSettings $settings, SocialIdentity $identity): RedirectResponse
{
$user = $request->user();
if ($user === null) {
return redirect()->route('login');
}
$link = $this->authenticator->link($user, $identity);
if ($link === null) {
return redirect()->route('connected-accounts.edit')->with(
'error',
__('That :provider account is already connected to another account here.', [
'provider' => $settings->provider->label(),
])
);
}
$this->activity->log(Action::SocialAccountLinked, $user, $user, [
'provider' => $settings->provider->label(),
]);
return redirect()->route('connected-accounts.edit')->with(
'success',
__(':provider connected.', ['provider' => $settings->provider->label()])
);
}
private function provider(string $provider): SocialProvider
{
return SocialProvider::tryFrom($provider) ?? throw new NotFoundHttpException;
}
}