diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php index bed8016d7..73649743a 100644 --- a/app/Actions/Fortify/ResetUserPassword.php +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -5,8 +5,12 @@ namespace App\Actions\Fortify; use App\Models\User; +use App\Providers\FortifyServiceProvider; +use Illuminate\Auth\Passwords\PasswordBroker; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Laravel\Fortify\Contracts\ResetsUserPasswords; class ResetUserPassword implements ResetsUserPasswords @@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords */ public function reset(User $user, array $input): void { + if (! FortifyServiceProvider::canResetPassword($user, $input)) { + /** @var PasswordBroker $broker */ + $broker = Password::broker(config('fortify.passwords')); + $broker->deleteToken($user); + + throw ValidationException::withMessages([ + 'email' => [__('This password reset link is invalid.')], + ]); + } + Validator::make($input, [ 'password' => $this->passwordRules(), ])->validate(); diff --git a/app/Models/User.php b/app/Models/User.php index 9fcea6ff2..ee8246fc3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -145,11 +145,21 @@ protected function defaultProfilePhotoUrl(): string return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; } - public function canAccessPanel(Panel $panel): bool + public function isSuperAdmin(): bool { return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail(); } + public function hasLocalPassword(): bool + { + return is_string($this->password) && $this->password !== ''; + } + + public function canAccessPanel(Panel $panel): bool + { + return $this->isSuperAdmin(); + } + public function isMemberOfOrganization(Organization $organization): bool { if ($this->relationLoaded('organizations')) { diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index c5be5d30f..aff378698 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -26,6 +26,7 @@ use Illuminate\Support\Facades\App; use Illuminate\View\Middleware\ShareErrorsFromSession; use Nwidart\Modules\Facades\Module; +use Nwidart\Modules\Laravel\Module as LaravelModule; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; class AdminPanelProvider extends PanelProvider @@ -91,22 +92,77 @@ public function panel(Panel $panel): Panel $modules = Module::allEnabled(); foreach ($modules as $module) { + $moduleNamespace = $this->getModuleAppNamespace($module); + $panel->discoverResources( in: module_path($module->getName(), 'app/Filament/Resources'), - for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources' + for: $moduleNamespace.'\\Filament\\Resources' ); $panel->discoverPages( in: module_path($module->getName(), 'app/Filament/Pages'), - for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages' + for: $moduleNamespace.'\\Filament\\Pages' ); $panel->discoverWidgets( in: module_path($module->getName(), 'app/Filament/Widgets'), - for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets' + for: $moduleNamespace.'\\Filament\\Widgets' ); } return $panel; } + + /** @var array Cache of module name => resolved app namespace. */ + private static array $moduleAppNamespaces = []; + + private function getModuleAppNamespace(LaravelModule $module): string + { + return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module); + } + + /** + * Resolve the PHP namespace mapped to a module's app/ directory so the + * Filament panel can discover its Resources/Pages/Widgets under the right + * namespace. + * + * Two module layouts currently coexist in this repo: + * - laravel-modules v12 (app_folder enabled): a bare namespace maps to + * app/ — e.g. "Extensions\SSO\" => app/, so classes are + * Extensions\SSO\Filament\... (this is the current convention). + * - the older layout: an "...\App" namespace maps to app/ — e.g. + * "Extensions\Billing\App\" => app/, so classes are + * Extensions\Billing\App\Filament\... + * + * The package's own namespace derivation assumes the v12 (bare) layout and + * would mis-resolve the legacy modules, so we read each module's composer + * PSR-4 map and use whichever namespace actually points at app/. The legacy + * "...\App" shape is only a fallback for when composer is missing/unreadable. + * Once every module adopts the bare layout this collapses to + * config('modules.namespace').'\\'.$module->getName(). + */ + private function resolveModuleAppNamespace(LaravelModule $module): string + { + $fallback = 'Extensions\\'.$module->getName().'\\App'; + + $composerPath = module_path($module->getName(), 'composer.json'); + $psr4 = []; + if (is_file($composerPath)) { + $composer = json_decode((string) file_get_contents($composerPath), true); + $psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : []; + } + + foreach ((array) $psr4 as $namespace => $path) { + if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') { + return rtrim($namespace, '\\'); + } + } + + return $fallback; + } + + private function normalizeComposerPath(mixed $path): string + { + return trim(str_replace('\\', '/', (string) $path), '/'); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 270a1571b..e44c443ca 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -25,6 +25,73 @@ class FortifyServiceProvider extends ServiceProvider { + /** + * Dummy bcrypt hash compared against when no user matches the submitted + * email. Hash::check is run against it so login takes the same time whether + * or not the email exists — otherwise an unknown email would skip the + * (deliberately slow) hash and return faster, letting an attacker enumerate + * registered accounts by timing the response. The plaintext is irrelevant: + * it is only ever checked against attacker-supplied input and never matches. + */ + private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'; + + /** + * Authorization rules applied AFTER the password is verified. Each rule + * receives the authenticated user + request and returns whether the login + * may proceed; any rule returning false denies it. This is an extension + * point: modules (e.g. SSO enforcement) add a rule to veto a password login + * instead of replacing this credential check — which would silently drift + * from the host logic the next time it changes. + * + * @var array + */ + protected static array $loginRules = []; + + /** + * Authorization rules applied before a password reset is completed. Rules + * receive the user being reset + submitted input and return whether the + * local reset flow may set a new password for that account. + * + * @var array): bool> + */ + protected static array $passwordResetRules = []; + + /** + * Register an additional rule that gates password login (see $loginRules). + * + * @param \Closure(User, Request): bool $rule + */ + public static function authenticateUsingRule(\Closure $rule): void + { + static::$loginRules[] = $rule; + } + + /** + * Register an additional rule that gates password reset completion. + * + * @param \Closure(User, array): bool $rule + */ + public static function resetPasswordUsingRule(\Closure $rule): void + { + static::$passwordResetRules[] = $rule; + } + + /** + * Check whether the given user may complete the local password reset flow. + * + * @param array $input + */ + public static function canResetPassword(User $user, array $input = []): bool + { + foreach (static::$passwordResetRules as $rule) { + if (! $rule($user, $input)) { + return false; + } + } + + return true; + } + /** * Register any application services. */ @@ -92,7 +159,23 @@ public function boot(): void ->where('is_placeholder', '=', false) ->first(); - if ($user !== null && Hash::check($request->password, $user->password)) { + // Always run the hash check — against the real hash, or a dummy when + // there is no user — so login timing is identical either way (see + // ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users + // have password = null) fail here, so they cannot password-login. + $existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH; + + $passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash); + + if ($user !== null && $passwordIsValid) { + // Credentials are valid; now apply any registered authorization + // rules (e.g. SSO enforcement may still block password login). + foreach (static::$loginRules as $rule) { + if (! $rule($user, $request)) { + return null; + } + } + return $user; } diff --git a/app/Service/UserService.php b/app/Service/UserService.php index 5cefb62f8..f4e58cf6d 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -48,6 +48,56 @@ public function createUser( } $user->save(); + $this->createDefaultOrganizationForUser( + $user, + $currency, + $numberFormat, + $currencyFormat, + $dateFormat, + $intervalFormat, + $timeFormat, + ); + + return $user; + } + + /** + * Create a user without a password (e.g. provisioned via SSO). Such users + * can only authenticate through a linked identity provider. + */ + public function createPasswordlessUser( + string $name, + string $email, + string $timezone, + Weekday $weekStart, + ?string $currency, + bool $verifyEmail = false + ): User { + $user = new User; + $user->name = $name; + $user->email = strtolower($email); + $user->password = null; + $user->timezone = $timezone; + $user->week_start = $weekStart; + if ($verifyEmail) { + $user->email_verified_at = Carbon::now(); + } + $user->save(); + + $this->createDefaultOrganizationForUser($user, $currency); + + return $user; + } + + private function createDefaultOrganizationForUser( + User $user, + ?string $currency, + ?NumberFormat $numberFormat = null, + ?CurrencyFormat $currencyFormat = null, + ?DateFormat $dateFormat = null, + ?IntervalFormat $intervalFormat = null, + ?TimeFormat $timeFormat = null, + ): void { $organizations = app(InvitationService::class)->processAcceptedInvitations($user); if ($organizations->isEmpty()) { @@ -64,8 +114,6 @@ public function createUser( ); $this->switchCurrentOrganization($user, $organization); } - - return $user; } /** diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index edfd8f62d..b0bf3f5a9 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue'; -defineProps({ - canResetPassword: Boolean, - status: String, -}); +withDefaults( + defineProps<{ + canResetPassword?: boolean; + status?: string; + }>(), + { + canResetPassword: false, + status: '', + } +); const form = useForm({ email: '', @@ -28,8 +34,8 @@ const submit = () => { }; const page = usePage<{ - flash: { - message: string; + flash?: { + message?: string; }; }>(); @@ -61,6 +67,9 @@ const page = usePage<{ {{ page.props.flash?.message }} + + +
Email @@ -103,5 +112,8 @@ const page = usePage<{ + + + diff --git a/resources/js/app.ts b/resources/js/app.ts index 229ce3cae..cb65dc28a 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'; import { type DefineComponent } from 'vue'; import { setupPrefetching } from '@/utils/prefetch'; +interface ExtensionManifest { + name?: string; + alias?: string; +} + const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const pinia = createPinia(); const queryClient = new QueryClient(); +const extensionManifests = import.meta.glob('../../extensions/**/module.json', { + eager: true, + import: 'default', +}) as Record; +// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app. +const extensionPages = import.meta.glob([ + '../../extensions/**/resources/js/Pages/**/*.vue', + '!**/BillingPortal.vue', +]); +const extensionDirectories = Object.entries(extensionManifests).reduce>( + (directories, [path, manifest]) => { + const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/); + const extensionDirectory = match?.[1]; + + if (extensionDirectory === undefined) { + return directories; + } + + for (const key of [manifest.name, manifest.alias, extensionDirectory]) { + if (typeof key !== 'string' || key === '') { + continue; + } + + directories[key] = extensionDirectory; + directories[key.toLowerCase()] = extensionDirectory; + } + + return directories; + }, + {} +); + +function resolveExtensionDirectory(moduleName: string): string { + return ( + extensionDirectories[moduleName] ?? + extensionDirectories[moduleName.toLowerCase()] ?? + moduleName + ); +} createInertiaApp({ title: (title) => `${title} - ${appName}`, resolve: (name) => { - if (name.includes('Invoicing::')) { - const [module, page] = name.split('::'); + // "Module::Page" (both halves present) resolves to that extension's page + // directory; everything else is a host page under resources/js/Pages. + const [module, ...pageSegments] = name.split('::'); + const page = pageSegments.join('::'); - const pagePath = module - ? `../../extensions/${module}/resources/js/Pages/${page}.vue` - : `./Pages/${page}.vue`; + if (module && page) { + const extensionDirectory = resolveExtensionDirectory(module); + const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`; - // BillingPortal is a Vue 2 Component and therefore should not be imported - const pages = module - ? import.meta.glob([ - '../../extensions/**/resources/js/Pages/*.vue', - '!**/BillingPortal.vue', - ]) - : import.meta.glob('./Pages/**/*.vue'); - - return resolvePageComponent(pagePath, pages); - } else { - return resolvePageComponent( - `./Pages/${name}.vue`, - import.meta.glob('./Pages/**/*.vue') - ); + return resolvePageComponent(pagePath, extensionPages); } + + return resolvePageComponent( + `./Pages/${name}.vue`, + import.meta.glob('./Pages/**/*.vue') + ); }, setup({ el, App, props, plugin }) { const app = createApp({ render: () => h(App, props) }); diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php index 457c2248a..0f89f60dd 100644 --- a/tests/Unit/Service/UserServiceTest.php +++ b/tests/Unit/Service/UserServiceTest.php @@ -5,8 +5,10 @@ namespace Tests\Unit\Service; use App\Enums\Role; +use App\Enums\Weekday; use App\Models\Member; use App\Models\Organization; +use App\Models\OrganizationInvitation; use App\Models\Project; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -135,4 +137,60 @@ public function make_sure_user_has_at_least_one_organization_creates_organizatio $this->assertSame(Role::Owner->value, $newMember->role); $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); } + + public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void + { + // Arrange — an accepted invitation exists for the email (e.g. the user + // followed the invite link, then signs up via SSO). Casing differs to + // prove the email is normalised before the invitation is matched. + $organization = Organization::factory()->create(); + OrganizationInvitation::factory() + ->forOrganization($organization) + ->role(Role::Employee) + ->accepted() + ->create([ + 'email' => 'invitee@example.com', + ]); + + // Act + $user = $this->userService->createPasswordlessUser( + 'Invitee', + 'Invitee@Example.com', + 'UTC', + Weekday::Monday, + null, + ); + + // Assert — invitation is materialised, no personal organization is created + $this->assertNull($user->password); + $this->assertDatabaseMissing(OrganizationInvitation::class, [ + 'email' => 'invitee@example.com', + ]); + $user->refresh(); + $this->assertSame(1, $user->organizations()->count()); + $this->assertSame($organization->getKey(), $user->organizations()->first()->getKey()); + $member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); + $this->assertSame(Role::Employee->value, $member->role); + } + + public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void + { + // Act + $user = $this->userService->createPasswordlessUser( + 'Solo User', + 'solo@example.com', + 'UTC', + Weekday::Monday, + null, + ); + + // Assert — a personal organization is created, owned by the user and set current + $user->refresh(); + $this->assertNull($user->password); + $this->assertSame(1, $user->organizations()->count()); + $organization = $user->organizations()->first(); + $this->assertTrue($organization->personal_team); + $this->assertSame($user->getKey(), $organization->user_id); + $this->assertSame($organization->getKey(), $user->currentOrganization->getKey()); + } }