Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/Actions/Fortify/ResetUserPassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
62 changes: 59 additions & 3 deletions app/Providers/Filament/AdminPanelProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<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), '/');
}
}
85 changes: 84 additions & 1 deletion app/Providers/FortifyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.
*/
Expand Down Expand Up @@ -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;
}

Expand Down
52 changes: 50 additions & 2 deletions app/Service/UserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -64,8 +114,6 @@ public function createUser(
);
$this->switchCurrentOrganization($user, $organization);
}

return $user;
}

/**
Expand Down
24 changes: 18 additions & 6 deletions resources/js/Pages/Auth/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -28,8 +34,8 @@ const submit = () => {
};

const page = usePage<{
flash: {
message: string;
flash?: {
message?: string;
};
}>();
</script>
Expand Down Expand Up @@ -61,6 +67,9 @@ const page = usePage<{
{{ page.props.flash?.message }}
</div>

<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />

<form @submit.prevent="submit">
<Field>
<FieldLabel for="email">Email</FieldLabel>
Expand Down Expand Up @@ -103,5 +112,8 @@ const page = usePage<{
</PrimaryButton>
</div>
</form>

<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard>
</template>
Loading
Loading