mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
f4fd194991
Make linking a provider re-prove the password
287 lines
21 KiB
PHP
287 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\Settings\PasswordController;
|
|
use App\Http\Controllers\Settings\ProfileController;
|
|
use App\Modules\Clients\Http\Controllers\ClientSettingsController;
|
|
use App\Modules\Comments\Http\Controllers\CommentSettingsController;
|
|
use App\Modules\Files\Http\Controllers\DownloadSettingsController;
|
|
use App\Modules\Files\Http\Controllers\FileRetentionSettingsController;
|
|
use App\Modules\Files\Http\Controllers\UploadSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\ApiTokensController;
|
|
use App\Modules\Identity\Http\Controllers\ConnectedAccountsController;
|
|
use App\Modules\Identity\Http\Controllers\LdapSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\SecuritySettingsController;
|
|
use App\Modules\Identity\Http\Controllers\SocialLoginController;
|
|
use App\Modules\Identity\Http\Controllers\SocialLoginSettingsController;
|
|
use App\Modules\Identity\Http\Controllers\TwoFactorEnrollmentController;
|
|
use App\Modules\Notifications\Http\Controllers\NotificationPreferencesController;
|
|
use App\Modules\Platform\Branding\Http\Controllers\BrandingController;
|
|
use App\Modules\Platform\Http\Controllers\AboutController;
|
|
use App\Modules\Platform\Http\Controllers\CaptchaSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\EmailOAuthController;
|
|
use App\Modules\Platform\Http\Controllers\EmailSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\EmailTemplatesController;
|
|
use App\Modules\Platform\Http\Controllers\ExternalStorageSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\GettingStartedController;
|
|
use App\Modules\Platform\Http\Controllers\LanguageSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\PrivacySettingsController;
|
|
use App\Modules\Platform\Http\Controllers\PublicListingSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\SchedulerMonitoringController;
|
|
use App\Modules\Platform\Http\Controllers\SystemSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\ThemingSettingsController;
|
|
use App\Modules\Platform\Http\Controllers\WhatsNewController;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
Route::middleware('auth')->group(function () {
|
|
// Route::redirect()'s destination needs a leading slash, or Laravel
|
|
// deliberately emits a *relative* Location header (stripping the
|
|
// leading slash it would otherwise generate) instead of an absolute
|
|
// one — see Illuminate\Routing\RedirectController. A relative
|
|
// redirect resolves against the current path's directory, which
|
|
// breaks the moment the redirect lives more than one segment deep
|
|
// (e.g. system/settings -> system/settings/general would resolve to
|
|
// system/system/settings/general). Always use an absolute path here.
|
|
Route::redirect('settings', '/settings/profile');
|
|
|
|
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
|
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
|
|
Route::get('settings/delete-account', [ProfileController::class, 'deleteAccount'])->name('profile.delete-account');
|
|
// Throttled for the same reason as confirm-password in routes/auth.php:
|
|
// destroy() verifies the account's password (`current_password`), and a
|
|
// credential check without a bucket is a credential check somebody can
|
|
// sit on. Named buckets, per the note at the top of routes/auth.php --
|
|
// a bare `throttle:` would share one counter with the public share
|
|
// links.
|
|
Route::delete('settings/profile', [ProfileController::class, 'destroy'])
|
|
->middleware('throttle:6,1,account-delete')
|
|
->name('profile.destroy');
|
|
|
|
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
|
|
// Same again: update() verifies `current_password` before it writes.
|
|
Route::put('settings/password', [PasswordController::class, 'update'])
|
|
->middleware('throttle:6,1,password-update')
|
|
->name('password.update');
|
|
|
|
Route::get('settings/two-factor', [TwoFactorEnrollmentController::class, 'show'])->name('two-factor.show');
|
|
|
|
// Staff and clients alike: which providers you have connected is a
|
|
// property of your account, not of your role.
|
|
Route::get('settings/connected-accounts', [ConnectedAccountsController::class, 'edit'])
|
|
->name('connected-accounts.edit');
|
|
// Behind password.confirm for the same reason as the two-factor block
|
|
// and the API tokens below: a SocialAccount row "*is* the
|
|
// authorization to sign in as that account", so it outlives the
|
|
// session that created it. It survives a password change, it survives
|
|
// Auth::logoutOtherDevices(), and it survives every session being
|
|
// invalidated -- which makes "attach my provider identity to your
|
|
// account" the most durable thing a stolen session can do. Starting
|
|
// the flow is what binds it, because the callback finishes with
|
|
// whichever provider account signed in at the other end, not
|
|
// necessarily the victim's.
|
|
Route::post('settings/connected-accounts/{provider}', [SocialLoginController::class, 'connect'])
|
|
->middleware(['password.confirm', 'throttle:20,1,social-connect'])
|
|
->name('connected-accounts.connect');
|
|
|
|
// Deliberately without it. Disconnecting removes a way in
|
|
// rather than adding one, and destroy() already refuses to remove the
|
|
// last one ("This is the only way you can sign in"). Requiring a
|
|
// password confirmation here would fall hardest on exactly the
|
|
// accounts that have no local password to confirm with -- the ones
|
|
// provisioned by a provider -- and leave them unable to disconnect
|
|
// anything.
|
|
Route::delete('settings/connected-accounts/{provider}', [ConnectedAccountsController::class, 'destroy'])
|
|
->name('connected-accounts.destroy');
|
|
|
|
// Changing the state of the second factor re-proves the first one. A
|
|
// stolen session is exactly the situation 2FA exists to survive, so
|
|
// "disable 2FA" must not be reachable with nothing but that session.
|
|
// `confirm` is deliberately outside this group: it is mid-enrollment,
|
|
// already proves possession of the TOTP secret, and the enrollment it
|
|
// completes was itself password-confirmed by `store`.
|
|
Route::middleware('password.confirm')->group(function () {
|
|
Route::post('settings/two-factor', [TwoFactorEnrollmentController::class, 'store'])->name('two-factor.enable');
|
|
Route::post('settings/two-factor/recovery-codes', [TwoFactorEnrollmentController::class, 'regenerateRecoveryCodes'])
|
|
->name('two-factor.recovery-codes');
|
|
Route::delete('settings/two-factor', [TwoFactorEnrollmentController::class, 'destroy'])->name('two-factor.disable');
|
|
});
|
|
|
|
// Named bucket — see the note in routes/auth.php. A bare `throttle:`
|
|
// shares one counter with every other bare one, so confirming an
|
|
// enrolment code used to draw on the same six as re-sending a
|
|
// verification email.
|
|
Route::post('settings/two-factor/confirm', [TwoFactorEnrollmentController::class, 'confirm'])
|
|
->middleware('throttle:6,1,two-factor-confirm')->name('two-factor.confirm');
|
|
|
|
// API tokens are account-level credentials, so they live in the personal
|
|
// settings section — but staff-only, because /api/v1 is staff-only.
|
|
// Creating and revoking re-prove the password for the same reason the
|
|
// two-factor block above does: a token outlives the session that minted
|
|
// it, so a stolen session must not be enough to mint one.
|
|
Route::middleware('staff')->group(function () {
|
|
// `create` before `{token}`, or the literal segment is swallowed by
|
|
// the id parameter — the same ordering rule as files/orphans in
|
|
// routes/web.php.
|
|
Route::get('settings/api-tokens/create', [ApiTokensController::class, 'create'])->name('api-tokens.create');
|
|
Route::get('settings/api-tokens', [ApiTokensController::class, 'index'])->name('api-tokens.index');
|
|
Route::get('settings/api-tokens/{token}/edit', [ApiTokensController::class, 'edit'])->name('api-tokens.edit');
|
|
|
|
Route::middleware('password.confirm')->group(function () {
|
|
Route::post('settings/api-tokens', [ApiTokensController::class, 'store'])->name('api-tokens.store');
|
|
// Editing can widen what an already-issued secret may do, so it
|
|
// re-proves the password exactly as minting does.
|
|
Route::patch('settings/api-tokens/{token}', [ApiTokensController::class, 'update'])->name('api-tokens.update');
|
|
Route::delete('settings/api-tokens/{token}', [ApiTokensController::class, 'destroy'])->name('api-tokens.destroy');
|
|
});
|
|
});
|
|
|
|
Route::get('settings/notifications', [NotificationPreferencesController::class, 'edit'])->name('notification-preferences.edit');
|
|
Route::put('settings/notifications', [NotificationPreferencesController::class, 'update'])->name('notification-preferences.update');
|
|
|
|
// Read-only, so `staff` alone rather than the `can:edit_settings`
|
|
// group below: the release you are running, its licence and where
|
|
// its source lives are not a configuration secret, and the sidebar
|
|
// footer links every staff member here regardless of what they may
|
|
// edit.
|
|
Route::middleware('staff')->get('system/about', AboutController::class)->name('system.about');
|
|
|
|
// Where a new installation's administrator is sent on their first
|
|
// visit, and a page any staff member can come back to. `staff` alone:
|
|
// it is a list of links to screens they can already reach, and
|
|
// QuickStart filters it to the ones they may actually use.
|
|
Route::middleware('staff')
|
|
->get('system/getting-started', GettingStartedController::class)
|
|
->name('system.getting-started');
|
|
|
|
// Where an administrator is sent after an update, and a page anyone
|
|
// who may read About's environment block can revisit afterwards. The
|
|
// capability keeps it off managed installations, where nobody signed
|
|
// in here performed the update it thanks them for.
|
|
Route::middleware(['staff', 'capability:system.updates', 'can:view_system_info'])
|
|
->get('system/whats-new', WhatsNewController::class)
|
|
->name('system.whats-new');
|
|
|
|
// System-wide configuration — deliberately outside the account
|
|
// settings section; it lives under its own sidebar entry, one route
|
|
// per section (general today; clients, email, … as modules land).
|
|
Route::middleware(['staff', 'can:edit_settings'])->group(function () {
|
|
Route::redirect('system/settings', '/system/settings/general');
|
|
Route::get('system/settings/general', [SystemSettingsController::class, 'edit'])->name('system-settings.edit');
|
|
Route::patch('system/settings/general', [SystemSettingsController::class, 'update'])->name('system-settings.update');
|
|
// Its own throttle bucket, like every other action route here: a
|
|
// bare `throttle:5,1` is keyed on the domain and the address, so
|
|
// it would share one allowance with everything else that omits a
|
|
// name. The controller enforces manage_updates and the edition,
|
|
// and applies an installation-wide cooldown of its own.
|
|
Route::post('system/settings/check-for-updates', [SystemSettingsController::class, 'checkForUpdates'])
|
|
->middleware('throttle:5,1,check-for-updates-now')
|
|
->name('system-settings.check-for-updates');
|
|
Route::get('system/settings/security', [SecuritySettingsController::class, 'edit'])->name('system-settings.security.edit');
|
|
Route::patch('system/settings/security', [SecuritySettingsController::class, 'update'])->name('system-settings.security.update');
|
|
Route::get('system/settings/clients', [ClientSettingsController::class, 'edit'])->name('system-settings.clients.edit');
|
|
Route::patch('system/settings/clients', [ClientSettingsController::class, 'update'])->name('system-settings.clients.update');
|
|
Route::get('system/settings/uploads', [UploadSettingsController::class, 'edit'])->name('system-settings.uploads.edit');
|
|
Route::patch('system/settings/uploads', [UploadSettingsController::class, 'update'])->name('system-settings.uploads.update');
|
|
Route::get('system/settings/downloads', [DownloadSettingsController::class, 'edit'])->name('system-settings.downloads.edit');
|
|
Route::patch('system/settings/downloads', [DownloadSettingsController::class, 'update'])->name('system-settings.downloads.update');
|
|
Route::get('system/settings/file-retention', [FileRetentionSettingsController::class, 'edit'])->name('system-settings.file-retention.edit');
|
|
Route::patch('system/settings/file-retention', [FileRetentionSettingsController::class, 'update'])->name('system-settings.file-retention.update');
|
|
Route::get('system/settings/comments', [CommentSettingsController::class, 'edit'])->name('system-settings.comments.edit');
|
|
Route::patch('system/settings/comments', [CommentSettingsController::class, 'update'])->name('system-settings.comments.update');
|
|
Route::get('system/settings/email', [EmailSettingsController::class, 'edit'])->name('system-settings.email.edit');
|
|
Route::patch('system/settings/email', [EmailSettingsController::class, 'update'])->name('system-settings.email.update');
|
|
Route::post('system/settings/email/test', [EmailSettingsController::class, 'sendTest'])->name('system-settings.email.test');
|
|
// The OAuth mailbox behind the Microsoft 365 provider. Its own
|
|
// throttle buckets like every action route here; the callback is
|
|
// a GET because it is the provider redirecting the admin's
|
|
// browser back, session and all.
|
|
Route::post('system/settings/email/oauth/connect', [EmailOAuthController::class, 'connect'])
|
|
->middleware('throttle:20,1,mail-oauth-connect')
|
|
->name('system-settings.email.oauth.connect');
|
|
Route::get('system/settings/email/oauth/callback', [EmailOAuthController::class, 'callback'])
|
|
->middleware('throttle:20,1,mail-oauth-callback')
|
|
->name('system-settings.email.oauth.callback');
|
|
Route::delete('system/settings/email/oauth', [EmailOAuthController::class, 'disconnect'])
|
|
->name('system-settings.email.oauth.disconnect');
|
|
// Deliberately outside any capability: group. LDAP is an
|
|
// administrator's setting, available in every edition, not an
|
|
// edition difference.
|
|
Route::get('system/settings/ldap', [LdapSettingsController::class, 'edit'])->name('system-settings.ldap.edit');
|
|
Route::patch('system/settings/ldap', [LdapSettingsController::class, 'update'])->name('system-settings.ldap.update');
|
|
Route::post('system/settings/ldap/test', [LdapSettingsController::class, 'test'])->name('system-settings.ldap.test');
|
|
|
|
// Outside any capability: group for the same reason as LDAP above.
|
|
Route::get('system/settings/social-login', [SocialLoginSettingsController::class, 'edit'])
|
|
->name('system-settings.social-login.edit');
|
|
Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update'])
|
|
->name('system-settings.social-login.update');
|
|
|
|
// Outside any capability: group for the same reason as LDAP above.
|
|
// Only the option of using the platform's own keys is an edition
|
|
// difference, and that is gated per field inside the controller.
|
|
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
|
|
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
|
|
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
|
|
|
|
Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit');
|
|
Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update');
|
|
Route::get('system/settings/public-listing', [PublicListingSettingsController::class, 'edit'])->name('system-settings.public-listing.edit');
|
|
Route::patch('system/settings/public-listing', [PublicListingSettingsController::class, 'update'])->name('system-settings.public-listing.update');
|
|
Route::get('system/settings/languages', [LanguageSettingsController::class, 'edit'])->name('system-settings.languages.edit');
|
|
Route::patch('system/settings/languages', [LanguageSettingsController::class, 'update'])->name('system-settings.languages.update');
|
|
Route::get('system/settings/theming', [ThemingSettingsController::class, 'edit'])->name('system-settings.theming.edit');
|
|
Route::patch('system/settings/theming', [ThemingSettingsController::class, 'update'])->name('system-settings.theming.update');
|
|
Route::get('system/settings/theming/email-preview/{key}', [ThemingSettingsController::class, 'previewEmail'])
|
|
->name('system-settings.theming.email-preview');
|
|
|
|
// Every field on this page is Community-only (Capability::StorageConfigure)
|
|
// — unlike email settings, there's no capability-independent
|
|
// sub-field to keep serving in Cloud, so the whole surface is
|
|
// gated at the route level instead of per-field in the controller.
|
|
Route::middleware('capability:storage.configure')->group(function () {
|
|
Route::get('system/settings/storage', [ExternalStorageSettingsController::class, 'edit'])->name('system-settings.storage.edit');
|
|
Route::patch('system/settings/storage', [ExternalStorageSettingsController::class, 'update'])->name('system-settings.storage.update');
|
|
Route::post('system/settings/storage/test', [ExternalStorageSettingsController::class, 'testConnection'])->name('system-settings.storage.test');
|
|
});
|
|
|
|
// Both editions since 2026-08-28, and gated all-or-nothing like
|
|
// Storage above: read included, so a staff member who may not
|
|
// change the logo never sees the page either. A managed
|
|
// installation on a plan without branding has the capability
|
|
// subtracted from its environment, and these 404 for it — the
|
|
// instance refusing is the enforcement, not the portal's screen.
|
|
Route::middleware('capability:branding.customize')->group(function () {
|
|
Route::get('system/settings/branding', [BrandingController::class, 'edit'])->name('branding.edit');
|
|
Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store');
|
|
Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy');
|
|
|
|
// POST rather than PATCH: the form carries a file, so it is
|
|
// multipart, and PHP only populates $_FILES for POST.
|
|
Route::post('system/settings/branding/watermark', [BrandingController::class, 'updateWatermark'])->name('branding.watermark.update');
|
|
Route::delete('system/settings/branding/watermark', [BrandingController::class, 'destroyWatermark'])->name('branding.watermark.destroy');
|
|
|
|
// The live sample on the settings screen: a GET with the values
|
|
// currently in the form, so it updates as they are adjusted
|
|
// rather than only after a save.
|
|
Route::get('system/settings/branding/watermark/sample', [BrandingController::class, 'watermarkSample'])->name('branding.watermark.sample');
|
|
});
|
|
|
|
// Same all-or-nothing shape as Storage above (Capability::SchedulerMonitoring).
|
|
Route::middleware('capability:scheduler.monitoring')->group(function () {
|
|
Route::get('system/settings/scheduler', [SchedulerMonitoringController::class, 'index'])->name('system-settings.scheduler.index');
|
|
Route::post('system/settings/scheduler/failed-jobs/{uuid}/retry', [SchedulerMonitoringController::class, 'retryFailedJob'])->name('system-settings.scheduler.retry');
|
|
Route::delete('system/settings/scheduler/failed-jobs/{uuid}', [SchedulerMonitoringController::class, 'destroyFailedJob'])->name('system-settings.scheduler.destroy');
|
|
Route::delete('system/settings/scheduler/failed-jobs', [SchedulerMonitoringController::class, 'destroyAllFailedJobs'])->name('system-settings.scheduler.destroy-all');
|
|
Route::patch('system/settings/scheduler/retention', [SchedulerMonitoringController::class, 'updateRetention'])->name('system-settings.scheduler.retention');
|
|
});
|
|
});
|
|
|
|
// Distinct from edit_settings, matching v1's exact permission split.
|
|
Route::middleware(['staff', 'can:edit_email_templates'])->group(function () {
|
|
Route::get('system/settings/email-templates', [EmailTemplatesController::class, 'index'])->name('email-templates.index');
|
|
Route::get('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'edit'])->name('email-templates.edit');
|
|
Route::patch('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'update'])->name('email-templates.update');
|
|
Route::delete('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'destroy'])->name('email-templates.destroy');
|
|
Route::get('system/settings/email-templates/{slot}/preview', [EmailTemplatesController::class, 'preview'])->name('email-templates.preview');
|
|
});
|
|
});
|