mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
495f3ae471
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.
191 lines
7.1 KiB
PHP
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;
|
|
}
|
|
}
|