Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}

// Device trust magic-links: signed + panel login handled in TrustDeviceController.
if (is_string($routeName) && $routeName === 'user-device.devices.trust') {
return $next($request);
}

$this->configureAuthFromConfig();

// Use Filament's auth check so we align with its panel access rules.
Expand Down
13 changes: 13 additions & 0 deletions packages/frontend-auth/tests/Feature/MooxFrontendAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,16 @@
$response->assertOk();
$response->assertSeeText('consumed');
});

it('allows guest access to user-device trust magic-link routes', function () {
config()->set('moox-frontend-auth.enabled', true);

Route::middleware(['web'])->get('/__user_device_trust_except', function () {
return response('trust-ok', 200);
})->name('user-device.devices.trust');

$response = $this->get('/__user_device_trust_except');

$response->assertOk();
$response->assertSeeText('trust-ok');
});
38 changes: 38 additions & 0 deletions packages/mail-template/src/Support/MailTemplateBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,42 @@ public static function toHtmlBySlug(string $slug, array $data = [], ?string $loc

return $renderer->toHtml($template, $data);
}

/**
* Localized template title (Filament “Betreff”) for use as the email subject.
*/
public static function titleBySlug(string $slug, ?string $locale = null): ?string
{
$slug = trim($slug);

if ($slug === '' || ! self::isAvailable()) {
return null;
}

$template = app(MailTemplateRenderer::class)->find($slug, $locale);

if ($template === null) {
return null;
}

$translationLocale = method_exists($template, 'getDefaultLocale')
? (string) $template->getDefaultLocale()
: (string) ($locale ?? app()->getLocale());

$translation = null;

if (method_exists($template, 'getTranslation')) {
$translation = $template->getTranslation($translationLocale, false);
}

if ($translation === null) {
$translation = $template->translations->first(
fn ($row): bool => strcasecmp((string) ($row->locale ?? ''), $translationLocale) === 0
) ?? $template->translations->first();
}

$title = trim((string) ($translation?->title ?? ''));

return $title !== '' ? $title : null;
}
}
10 changes: 5 additions & 5 deletions packages/user-device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ Curious what the install command does? See manual installation below.
- If the record is **new**, the user receives an email with a **signed “Trust this device” link**.

2. **Device is untrusted (`whitelisted = false`)**
- The user is **hard-blocked** in Filament: any attempt to navigate away will be redirected back to the devices page with a single notification.
- The user must click the **email trust link** to continue.
- Panel access is blocked: the user is logged out and returned to the **login screen** with a toast (check email / confirm device).
- The user must click the **email trust link** before they can sign in.

3. **Trusting a device**
- The signed link marks the device as `whitelisted = true`.
- After that, the user can use Filament normally.
- After that, the user can sign in and use Filament normally.

4. **Admin actions**
- If Filament Shield / Spatie Permission is available, **super_admin** can:
Expand All @@ -49,7 +49,7 @@ Curious what the install command does? See manual installation below.
### Track-only mode (`enforce_trust=false`)

- Devices are still created/updated on login and linked to the session.
- No hard-block middleware and no new-device email.
- No hard-block middleware; new-device email is still sent (notify-only, without trust CTA).
- New devices are stored as trusted (`whitelisted=true`).

### What the package ships
Expand Down Expand Up @@ -83,7 +83,7 @@ Curious what the install command does? See manual installation below.
### Configuration (config/user-device.php)

- `enabled` (bool): device tracking on login + session sync (default: false, env: `USER_DEVICE_ENABLED`)
- `enforce_trust` (bool): hard-block + trust mail for new devices (default: true, env: `USER_DEVICE_ENFORCE_TRUST`)
- `enforce_trust` (bool): hard-block + trust CTA in mail (default: true, env: `USER_DEVICE_ENFORCE_TRUST`). New-device mail is sent whenever tracking is enabled.
- `trust_link_expires_minutes` (int): signed trust link expiry
- `scope_to_authenticated_user` (bool): always scope resource to the current user
- `allow_all_devices_without_shield` (bool): allow viewing all devices if Shield is not installed
Expand Down
18 changes: 16 additions & 2 deletions packages/user-device/config/user-device.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
|--------------------------------------------------------------------------
|
| When true (default), untrusted devices are hard-blocked in Filament until
| confirmed via email trust link or admin Trust action.
| When false, devices are tracked only — no login gate.
| confirmed via email trust link or admin Trust action, and the new-device
| mail includes a trust CTA.
| When false, devices are tracked only — no login gate; new-device mail is
| still sent without a trust CTA (notify-only).
|
*/
'enforce_trust' => env('USER_DEVICE_ENFORCE_TRUST', true),
Expand Down Expand Up @@ -141,10 +143,22 @@
|--------------------------------------------------------------------------
|
| Either a full URL (https://...) or a public path (/logo/foo.svg).
| Used only for the Blade fallback when moox/mail-template is unavailable.
|
*/
'mail_logo_url' => '/logo/logo_heco_2021.svg',

/*
|--------------------------------------------------------------------------
| Mail template slug
|--------------------------------------------------------------------------
|
| When moox/mail-template is installed, NewDeviceNotification renders this
| MailTemplate slug (layout login-link) instead of the package Blade view.
|
*/
'mail_template_slug' => env('USER_DEVICE_MAIL_TEMPLATE_SLUG', 'new-device'),

/*
|--------------------------------------------------------------------------
| Audit defaults
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ return new class extends Migration
$table->string('slug')->unique();
$table->string('scope')->nullable()->index();

$table->morphs('user');
// string morph: supports bigint user ids and UUID authenticatables (e.g. contacts)
$table->string('user_id');
$table->string('user_type');
$table->index(['user_id', 'user_type']);

$table->text('user_agent')->nullable();
$table->string('os')->nullable();
Expand Down
5 changes: 3 additions & 2 deletions packages/user-device/resources/lang/de/translations.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
'mail_label_system' => 'System',
'mail_label_ip' => 'IP-Adresse',
'mail_label_location' => 'Ort',
'mail_location_unknown' => 'Nicht ermittelbar',
'mail_if_it_was_you' => 'Wenn du das selbst warst, kannst du diese E‑Mail ignorieren.',
'mail_if_it_was_not_you' => 'Wenn du das nicht warst:',
'mail_step_review_devices' => 'prüfe deine Geräte‑Liste',
Expand All @@ -29,8 +30,8 @@
'mail_outro_secure_account' => 'Wenn das nicht du warst, sichere bitte dein Benutzerkonto.',

// Enforcement
'device_blocked_title' => 'Geräte-Bestätigung erforderlich',
'device_blocked_body' => 'Bitte bestätige dieses Gerät über den Link aus der E‑Mail, die wir dir gesendet haben.',
'device_blocked_title' => 'Neues Gerät erkannt',
'device_blocked_body' => 'Dieses Gerät ist noch nicht bestätigt. Wir haben dir eine E‑Mail geschickt. Bitte öffne den Bestätigungslink darin, danach kannst du dich anmelden.',

Check warning on line 34 in packages/user-device/resources/lang/de/translations.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this 179 characters long line (which is greater than 120 authorized).

See more on https://sonarcloud.io/project/issues?id=mooxphp_moox&issues=AaDYT3eFLwGUmxDWA5P5&open=AaDYT3eFLwGUmxDWA5P5&pullRequest=1084

// Devices
'device_trusted' => 'Vertraut',
Expand Down
5 changes: 3 additions & 2 deletions packages/user-device/resources/lang/en/translations.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'mail_label_system' => 'System',
'mail_label_ip' => 'IP address',
'mail_label_location' => 'Location',
'mail_location_unknown' => 'Unknown',
'mail_if_it_was_you' => 'If this was you, you can ignore this email.',
'mail_if_it_was_not_you' => 'If this was not you:',
'mail_step_review_devices' => 'Review your devices',
Expand All @@ -36,8 +37,8 @@
'mail_outro_secure_account' => 'If this was not you, please secure your account.',

// Enforcement
'device_blocked_title' => 'Device confirmation required',
'device_blocked_body' => 'Please confirm this device using the link from the email we sent you.',
'device_blocked_title' => 'New device detected',
'device_blocked_body' => 'This device has not been confirmed yet. We sent you an email. Please open the confirmation link in it, then you can sign in.',

Check warning on line 41 in packages/user-device/resources/lang/en/translations.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this 156 characters long line (which is greater than 120 authorized).

See more on https://sonarcloud.io/project/issues?id=mooxphp_moox&issues=AaDYT3iqLwGUmxDWA5P6&open=AaDYT3iqLwGUmxDWA5P6&pullRequest=1084

// Devices
'device_trusted' => 'Trusted',
Expand Down
18 changes: 9 additions & 9 deletions packages/user-device/resources/views/mail/new-device.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,19 @@
<li>{{ __('user-device::translations.mail_step_check_mfa') }}</li>
</ul>

<div style="margin:0 0 14px;">
@if(filled($trustUrl))
@if(($enforceTrust ?? true) && filled($trustUrl))
<div style="margin:0 0 14px;">
<a href="{{ $trustUrl }}" style="display:inline-block; background:#111827; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:10px; font-size:14px; font-weight:600;">
{{ __('user-device::translations.mail_cta_trust_device') }}
</a>
@endif
</div>
</div>

<p style="margin:0 0 14px; font-size:12px; line-height:18px; color:#6b7280;">
<a href="{{ $reviewUrl }}" style="color:#111827; text-decoration:underline;">
{{ __('user-device::translations.mail_cta_review_devices') }}
</a>
</p>
<p style="margin:0 0 14px; font-size:12px; line-height:18px; color:#6b7280;">
<a href="{{ $reviewUrl }}" style="color:#111827; text-decoration:underline;">
{{ __('user-device::translations.mail_cta_review_devices') }}
</a>
</p>
@endif

<p style="margin:0; font-size:12px; line-height:18px; color:#6b7280;">
{{ __('user-device::translations.mail_outro_secure_account') }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{!! $html !!}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@

namespace Moox\UserDevice\Http\Controllers;

use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Moox\UserDevice\Models\UserDevice;
use Moox\UserDevice\Resources\UserDeviceResource;
use Throwable;

class TrustDeviceController
{
public function __invoke(Request $request, string $panel, int $device): RedirectResponse
{
$this->setFilamentPanel($panel);

if (! filament()->auth()->check()) {
session()->put('url.intended', $request->fullUrl());

return redirect()->to(UserDeviceResource::getUrl('index', panel: $panel));
return redirect()->to($this->loginUrl($panel));
}

$authUser = filament()->auth()->user();
Expand All @@ -29,6 +33,33 @@ public function __invoke(Request $request, string $panel, int $device): Redirect

$record->update(['whitelisted' => true]);

return redirect()->to(UserDeviceResource::getUrl('index', panel: $panel));
try {
$home = filament()->getUrl();
if (filled($home)) {
return redirect()->to($home);
}
} catch (Throwable) {
//
}

return redirect()->to($this->loginUrl($panel));
}

protected function setFilamentPanel(string $panelId): void
{
try {
Filament::setCurrentPanel(Filament::getPanel($panelId));
} catch (Throwable) {
// Invalid panel id — leave default panel; auth/ownership checks still apply.
}
}

protected function loginUrl(string $panelId): string
{
try {
return filament()->getLoginUrl() ?? UserDeviceResource::getUrl('index', panel: $panelId);
} catch (Throwable) {
return UserDeviceResource::getUrl('index', panel: $panelId);
}
}
}
45 changes: 11 additions & 34 deletions packages/user-device/src/Http/Middleware/EnsureTrustedDevice.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
namespace Moox\UserDevice\Http\Middleware;

use Closure;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Moox\UserDevice\Models\UserDevice;
use Moox\UserDevice\Resources\UserDeviceResource;
use Spatie\Permission\PermissionRegistrar;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class EnsureTrustedDevice
{
Expand All @@ -38,13 +37,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}

$path = '/'.ltrim((string) $request->path(), '/');

// Always allow navigating to device management (otherwise users get stuck).
if ($this->isUserDeviceResourceRequest($path)) {
return $next($request);
}

$sessionId = session()->getId();

if (blank($sessionId)) {
Expand All @@ -58,8 +50,9 @@ public function handle(Request $request, Closure $next): Response
}

// Fallback: resolve device by user+ip (covers session-regeneration edge cases).
// Use the Filament-authenticated user id — Auth::id() is the wrong guard on portal.
if (blank($deviceId)) {
$userId = Auth::id();
$userId = $user->getAuthIdentifier();
if (blank($userId)) {
return $next($request);
}
Expand Down Expand Up @@ -100,41 +93,25 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}

// Hard block: while untrusted, the user can only access the devices page + trust link.
// Untrusted: kick back to the login screen with a toast (no panel access).
Notification::make()
->title(__('user-device::translations.device_blocked_title'))
->body(__('user-device::translations.device_blocked_body'))
->danger()
->send();

$panelId = filament()->getCurrentPanel()?->getId();

$devicesUrl = filled($panelId)
? UserDeviceResource::getUrl('index', panel: $panelId)
: UserDeviceResource::getUrl('index');
filament()->auth()->logout();

return redirect()->to($devicesUrl);
return redirect()->to($this->loginUrl());
}

private function isUserDeviceResourceRequest(string $path): bool
private function loginUrl(): string
{
if (! class_exists(Filament::class)) {
return false;
try {
return (string) (filament()->getLoginUrl() ?? '/');
} catch (Throwable) {
return '/';
}

foreach (Filament::getPanels() as $panel) {
try {
$devicesIndexPath = parse_url(UserDeviceResource::getUrl('index', panel: $panel->getId()), PHP_URL_PATH);

if (is_string($devicesIndexPath) && $devicesIndexPath !== '' && str_starts_with($path, $devicesIndexPath)) {
return true;
}
} catch (\Throwable) {
// ignore panels where the resource is not registered
}
}

return false;
}

private function isShieldAdmin(object $user): bool
Expand Down
Loading
Loading