Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
dbc927b6a9 Add core support for extendable authentication
add support for passwordless user creation; add filament loading support
for new laravel modules namespacing;
add support for pluggable password reset and login rules
2026-06-23 18:32:54 +02:00
12 changed files with 2521 additions and 1189 deletions

View File

@@ -5,8 +5,12 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void 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, [ Validator::make($input, [
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();

View File

@@ -145,11 +145,21 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; 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(); 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 public function isMemberOfOrganization(Organization $organization): bool
{ {
if ($this->relationLoaded('organizations')) { if ($this->relationLoaded('organizations')) {

View File

@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
@@ -91,22 +92,77 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled(); $modules = Module::allEnabled();
foreach ($modules as $module) { foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources( $panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'), in: module_path($module->getName(), 'app/Filament/Resources'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources' for: $moduleNamespace.'\\Filament\\Resources'
); );
$panel->discoverPages( $panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'), in: module_path($module->getName(), 'app/Filament/Pages'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages' for: $moduleNamespace.'\\Filament\\Pages'
); );
$panel->discoverWidgets( $panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'), in: module_path($module->getName(), 'app/Filament/Widgets'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets' for: $moduleNamespace.'\\Filament\\Widgets'
); );
} }
return $panel; return $panel;
} }
/** @var array<string, string> 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), '/');
}
} }

View File

@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider 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<int, \Closure(User, Request): bool>
*/
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<int, \Closure(User, array<string, mixed>): 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<string, mixed>): 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<string, mixed> $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. * Register any application services.
*/ */
@@ -92,7 +159,23 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->first(); ->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; return $user;
} }

View File

@@ -48,6 +48,56 @@ class UserService
} }
$user->save(); $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); $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) { if ($organizations->isEmpty()) {
@@ -64,8 +114,6 @@ class UserService
); );
$this->switchCurrentOrganization($user, $organization); $this->switchCurrentOrganization($user, $organization);
} }
return $user;
} }
/** /**

3299
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,13 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^3.4.0", "@inertiajs/vue3": "^2.3.23",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@types/node": "^25.9.3", "@types/node": "^22.19.19",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
@@ -40,14 +40,14 @@
"axios": "^1.16.0", "axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9", "happy-dom": "^20.8.9",
"laravel-vite-plugin": "^3.1.0", "laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.18.3", "openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"postcss-import": "^16.1.1", "postcss-import": "^15.1.0",
"postcss-nesting": "^14.0.0", "postcss-nesting": "^12.1.5",
"tailwindcss": "^4.3.1", "tailwindcss": "^3.4.19",
"typescript": "^6.0.3", "typescript": "^5.9.3",
"vite": "^8.0.16", "vite": "^7.3.3",
"vite-plugin-checker": "^0.12.0", "vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4", "vitest": "^4.1.4",
"vue": "^3.5.34", "vue": "^3.5.34",
@@ -55,14 +55,14 @@
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^2.0.0", "@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0", "@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0", "@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^6.1.34", "@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
@@ -79,7 +79,7 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.9.7", "reka-ui": "^2.9.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1", "vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1", "vue-echarts": "^8.0.1",

View File

@@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
defineProps({ withDefaults(
canResetPassword: Boolean, defineProps<{
status: String, canResetPassword?: boolean;
}); status?: string;
}>(),
{
canResetPassword: false,
status: '',
}
);
const form = useForm({ const form = useForm({
email: '', email: '',
@@ -28,8 +34,8 @@ const submit = () => {
}; };
const page = usePage<{ const page = usePage<{
flash: { flash?: {
message: string; message?: string;
}; };
}>(); }>();
</script> </script>
@@ -61,6 +67,9 @@ const page = usePage<{
{{ page.props.flash?.message }} {{ page.props.flash?.message }}
</div> </div>
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
@@ -103,5 +112,8 @@ const page = usePage<{
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard> </AuthenticationCard>
</template> </template>

View File

@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue'; import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch'; import { setupPrefetching } from '@/utils/prefetch';
interface ExtensionManifest {
name?: string;
alias?: string;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia(); const pinia = createPinia();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
eager: true,
import: 'default',
}) as Record<string, ExtensionManifest>;
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
const extensionPages = import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/**/*.vue',
'!**/BillingPortal.vue',
]);
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
(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({ createInertiaApp({
title: (title) => `${title} - ${appName}`, title: (title) => `${title} - ${appName}`,
resolve: (name) => { resolve: (name) => {
if (name.includes('Invoicing::')) { // "Module::Page" (both halves present) resolves to that extension's page
const [module, page] = name.split('::'); // directory; everything else is a host page under resources/js/Pages.
const [module, ...pageSegments] = name.split('::');
const page = pageSegments.join('::');
const pagePath = module if (module && page) {
? `../../extensions/${module}/resources/js/Pages/${page}.vue` const extensionDirectory = resolveExtensionDirectory(module);
: `./Pages/${page}.vue`; const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
// BillingPortal is a Vue 2 Component and therefore should not be imported return resolvePageComponent(pagePath, extensionPages);
const pages = module
? import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/*.vue',
'!**/BillingPortal.vue',
])
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
return resolvePageComponent(pagePath, pages);
} else {
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
} }
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
}, },
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) }); const app = createApp({ render: () => h(App, props) });

View File

@@ -29,7 +29,7 @@
"author": "solidtime", "author": "solidtime",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^5.0.2" "vite-plugin-dts": "^4.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",

View File

@@ -50,7 +50,7 @@
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"vite-plugin-dts": "^5.0.2", "vite-plugin-dts": "^4.5.4",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
$this->assertSame(Role::Owner->value, $newMember->role); $this->assertSame(Role::Owner->value, $newMember->role);
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); $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());
}
} }