Skip to content

Authentication

Intro

Every portal needs authentication.
This package provides some livewire components to add the features of: login, registration, email verification and logout.
More will need to be added later.

See generic doc for using livewire components

Components

All auth components are registered as named Livewire components using the application handle. For a portal application they can be used in two ways:

As a full-page route (the component renders with its own layout):

php
Route::get('/login', LoginCard::class)->name('login');

Embedded in a Blade template (useful for inline forms or custom pages):

blade
<livewire:application.portal.auth.login-card />
<livewire:application.portal.auth.register-card />
<livewire:application.portal.auth.forgot-password-card />
<livewire:application.portal.auth.verify-email-card />
<livewire:application.portal.auth.welcome-card />
<livewire:application.portal.auth.logout-button />
<livewire:application.portal.auth.passkey-manager />
<livewire:application.portal.auth.confirm-password-card />

When embedded, the component renders without its layout wrapper — only the form itself is output. The layout is only applied when used as a full-page Livewire route.

Routing

This snippet is copied from the routes/portal.php, and might not be up to date with the latest version.

php
// GUEST ONLY
Route::group(['middleware' => array_filter([
    'qore-portal-guest',
])],
    function (): void {
        Route::get('/', fn () => view('application.portal.pages.home'))->name('home');
        Route::get('/login', LoginCard::class)->name('login');
        Route::get('/register', RegisterCard::class)->name('register');
        Route::get('/welcome/{user}', WelcomeCard::class)->middleware(WelcomesNewUser::class)->name('welcome');
 });

// LOGGED IN
Route::group(['middleware' => array_filter([
    'qore-portal-auth',
])],
    function (): void {
        Route::get('/email/verify', VerifyEmailCard::class)->name('verification.notice');
        Route::get('/email/verified', VerifiedEmailCard::class)->name('verification.verified');
        Route::get('/email/verify/{id}/{hash}', EmailVerificationVerify::class)
            ->middleware(['signed', 'throttle:6,1'])
            ->name('verification.verify');

        Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
    });

Login

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\LoginCard

Shows a "forgot password" link when the password.request route is registered. To change this or other visual behaviour, publish the views and modify login-card.blade.php:

bash
php artisan vendor:publish --tag=qore-frontend-views

For advanced behavioural changes (form logic, redirects, validation), extend the LoginCard Livewire class rather than editing the view:

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\LoginCard as BaseLoginCard;

class LoginCard extends BaseLoginCard
{
    // override methods here
}

Registration

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\RegisterCard

Email Verification

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\VerifyEmailCard;
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\VerifiedEmailCard;

Welcome set password

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\WelcomeCard

Forgot password + reset

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\ForgotPasswordCard

Logout button

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\LogoutButton

A Livewire button component that handles the full logout flow: invalidates the guard session and redirects to the configured logout_route. Renders as a Flux UI button.

blade
<livewire:application.portal.auth.logout-button />

Extra HTML attributes are passed through to the underlying <flux:button>, so you can control variant, size, and any other Flux prop:

blade
<livewire:application.portal.auth.logout-button variant="ghost" />
<livewire:application.portal.auth.logout-button variant="danger" size="sm" />

The logout logic itself lives in LogoutUserAction, which is also used internally by AuthenticatedSessionController::destroy(). To customise behaviour, extend the class:

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\LogoutButton as BaseLogoutButton;

class LogoutButton extends BaseLogoutButton
{
    public function logout(): void
    {
        // custom pre-logout logic
        parent::logout();
    }
}

The redirect target after logout is controlled by feature_config.login.logout_route (see Configuration table below).

Configuration

KeyTypeDefaultDescription
features.registrationbooltrueEnable registration feature
feature_config.registration.email_verification_routestringapplication.portal.verification.noticeRoute name for email verification page. Used in a redirect after registration.
feature_config.registration.registration_response_routestringapplication.portal.dashboardRoute name for dashboard page. Used in a redirect after registration
features.login.redirect_if_authenticated_routestringapplication.portal.dashboardRoute for redirect if authenticated on guest route.
features.login.logout_routestringapplication.portal.homeRoute for redirect after logout.
fortify.confirm_passwordboolfalseGate passkey and 2FA management behind a recent password confirmation.
fortify.require_two_factorboolfalseForce users to enrol 2FA before using the portal (redirects to the setup screen).
fortify.two_factor_remember_daysint30Days a device stays trusted after a 2FA challenge before re-prompting (0 = always).
passkeys.redirect_routestringapplication.portal.dashboardRoute name a passkey login redirects to (any intended URL still wins).

Passkeys (WebAuthn)

Passwordless sign-in and passkey management, built on Laravel Fortify and the laravel/passkeys package. The passkey login button is rendered on the LoginCard, and authenticated users manage their passkeys with the PasskeyManager component.

Activating passkeys takes a few steps: prepare the model, enable the feature, run the migration, register the routes, and — importantly — activate the frontend JavaScript.

1. Prepare the User model

Implement PasskeyUser and use the PasskeyAuthenticatable trait:

php
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;

class User extends QoreAdminBaseUser implements MustVerifyEmail, PasskeyUser
{
    use PasskeyAuthenticatable;
}

2. Enable the feature

Enable passkeys on the panel plugin so the sign-in button is shown on the LoginCard:

php
QoreFrontendPlugin::make()->setPasskeys(true)

Fortify's passkeys feature is enabled by default in config/fortify.php, so no extra feature configuration is required.

3. Publish and run the migration

bash
php artisan vendor:publish --tag=passkeys-migrations
php artisan migrate

4. Register the routes

Add the passkey routes to routes/portal.php. They inherit the portal's name prefix and guards, and the SetPasskeyGuard middleware resolves the passkey guard and the WebAuthn relying party from the active application at runtime:

php
use QoreWorksBusiness\QoreFrontend\QoreFrontendRouting;

QoreFrontendRouting::passkeyRoutes();

The login endpoints are public guest routes. To throttle them you must name a limiter in config/fortify.php — see Rate limiting below. Without it the routes work but are unthrottled.

This registers the following routes (under the application.portal. name prefix):

Route nameMethodAccessDescription
passkey.login-options / passkey.loginGET / POSTguestPasswordless sign-in ceremony
passkey.confirm-options / passkey.confirmGET / POSTauthConfirm identity with an existing passkey
passkey.registration-options / passkey.store / passkey.destroyGET / POST / DELETEauth + password confirmRegister and delete passkeys

5. Activate the frontend JavaScript

The passkey ceremony runs in the browser through the official @laravel/passkeys client. Scaffolded portals ship a passkeys.js (it registers the passkeyLogin and passkeyManager Alpine components), but it is not imported by default — you must opt in.

Install the client and import the script in your portal entrypoint, then rebuild:

bash
npm install @laravel/passkeys
js
// resources/js/application/portal/app.js
import './bootstrap';
import './passkeys';
bash
npm run build

Relying party & allowed origins

A passkey is bound to a relying party ID (a domain) and only completes when the browser's origin is on the allowed list. Fortify derives both from app.url, which only works for the application served on that exact host. Because each frontend application runs on its own domain, SetPasskeyGuard rebinds the ceremony to the active application's domain at runtime — so passkeys work on the portal/webapp out of the box without touching config/fortify.php.

This rebinding only happens when the application's domain config is set (config/<handle>.php, e.g. 'domain' => env('PORTAL_DOMAIN')). If it is empty, the relying party falls back to Fortify's app.url-derived value, which breaks ceremonies served on a different host.

By default a passkey is therefore scoped to the application that created it. To share one relying party across sibling subdomains (e.g. app., portal., mijn. of example.com) — so a single passkey works everywhere — set a common parent domain and list every origin in the application's own config (config/<handle>.php):

php
// config/portal.php
'passkeys' => [
    // RP ID must be a parent domain of every origin below.
    'relying_party_id' => 'example.com',
    'allowed_origins' => [
        'https://app.example.com',
        'https://portal.example.com',
        'https://mijn.example.com',
    ],
],

The relying party ID must be a registrable suffix of each origin's host. app.example.com is not valid for an origin on portal.example.com (sibling subdomains); their shared parent example.com is.

Login redirect

After a successful passkey sign-in the package sends the user to the active application's dashboard, mirroring the password-based LoginResponse. Laravel Passkeys otherwise defaults passkeys.redirect to Fortify's home path (/home), which is not a route in the frontend applications.

Override the destination per application with the passkeys.redirect_route config (a route name). Any intended URL captured before authentication still takes precedence over it.

php
// config/portal.php
'passkeys' => [
    'redirect_route' => 'application.portal.dashboard',
],

Rate limiting

The passkey login endpoints are public guest routes, so throttling them is strongly recommended. passkeyRoutes() only attaches throttling when config('fortify.limiters.passkeys') is set, and the package only registers the limiter in that same case — Fortify defaults this value to null, so out of the box the endpoints are not throttled. Opt in by naming a limiter in config/fortify.php:

php
// config/fortify.php
'limiters' => [
    'passkeys' => 'passkeys',
],

With that set, the package registers the named limiter automatically (5 requests/minute, keyed by authenticated user or IP) and passkeyRoutes() applies it. Register your own limiter under the same name to customise it, or leave the config null to disable throttling entirely.

If passkeys.js is missing (for example on an older scaffold), copy it from the package stub at vendor/qore-works-business/qore-frontend/resources/stubs/integrations/portal/resources/js/passkeys.js.

Managing passkeys

Authenticated users register and delete passkeys with the PasskeyManager component:

blade
<livewire:application.portal.auth.passkey-manager />

Styling. The component renders the package view qore-frontend::application.portal.livewire.auth.passkey-manager. To match your application's design, override it without touching the package by creating resources/views/vendor/qore-frontend/application/portal/livewire/auth/passkey-manager.blade.php — keep the x-data="passkeyManager({...})" bindings and the wire:click="deletePasskey(...)" action, restyle the markup around them.

Managing passkeys can optionally be gated behind a recent password confirmation. This is disabled by default — enable it explicitly in config/portal.php. The same fortify.confirm_password flag also gates two-factor management:

php
// config/portal.php
'fortify' => [
    'confirm_password' => true,
],

When enabled, the management routes (passkey.registration-options, passkey.store, passkey.destroy) require a recent password confirmation. The passkeyRoutes() helper already attaches the ConfirmPassword middleware to them, so no extra wiring is needed there — the middleware is a no-op while the config is false.

The package ships a ConfirmPasswordCard for the confirmation screen. Register it as the password.confirm route inside the authenticated group:

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Livewire\auth\ConfirmPasswordCard;

Route::get('/user/confirm-password', ConfirmPasswordCard::class)->name('password.confirm');

To gate your own pages (such as a security/account page that embeds the PasskeyManager), apply the portal ConfirmPassword middleware. It respects the same confirm_password config:

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Http\Middleware\Auth\ConfirmPassword;

Route::view('/account/security', 'application.portal.pages.security')
    ->middleware(ConfirmPassword::class)
    ->name('account.security');

Two-Factor Authentication (TOTP)

Time-based one-time-password (TOTP) two-factor authentication, built on Laravel Fortify. Authenticated users enable and manage 2FA with the TwoFactorManager component; a 2FA-enabled user is challenged for a code after entering their password at login (TwoFactorChallengeCard).

Unlike passkeys, 2FA management is pure Livewire — it calls Fortify's actions server-side, so there is no JavaScript or AJAX endpoint to wire up.

1. Prepare the User model

Add the TwoFactorAuthenticatable trait:

php
use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends QoreAdminBaseUser implements MustVerifyEmail, PasskeyUser
{
    use TwoFactorAuthenticatable;
}

2. Enable the feature

Activate 2FA on the panel plugin — this is the master switch for the 2FA behaviour (mirrors setPasskeys):

php
QoreFrontendPlugin::make()->setTwoFactor(true)

When the flag is off (the default), the login challenge (RedirectIfTwoFactorAuthenticatable) and the mandatory-enrolment middleware (RequireTwoFactor) both no-op, so users are never challenged or forced to enrol even if they have a secret. The TwoFactorManager (enable/disable/recovery) still works regardless — only embed it where you intend 2FA to be usable.

The Fortify feature twoFactorAuthentication is enabled by default in config/fortify.php and gates route registration (twoFactorRoutes()) — keep it enabled. The plugin flag gates runtime behaviour on top of it. To remove 2FA entirely, drop Features::twoFactorAuthentication() from config/fortify.php.

3. Publish and run the migration

The two_factor_secret, two_factor_recovery_codes and two_factor_confirmed_at columns live on the users table and come from Fortify's migration:

bash
php artisan vendor:publish --tag=fortify-migrations
php artisan migrate

4. Register the routes

Add the two-factor routes to routes/portal.php. They inherit the portal's name prefix and guards:

php
use QoreWorksBusiness\QoreFrontend\QoreFrontendRouting;

QoreFrontendRouting::twoFactorRoutes();

This registers two routes (under the application.portal. name prefix):

Route nameMethodAccessDescription
two-factor.loginGETguestCode / recovery-code login challenge (TwoFactorChallengeCard)
two-factor.setupGETauthEnrolment screen (TwoFactorSetupCard), used by the Mandatory 2FA redirect

The authenticated management actions (enable, confirm, regenerate recovery codes, disable) are handled by the TwoFactorManager Livewire component directly — no extra routes.

The challenge endpoint is a public guest route. To throttle it, name a limiter in config/fortify.php under limiters.two-factor — the package then registers it automatically (5 requests/minute) and twoFactorRoutes() applies it.

5. Login challenge

When 2FA is enabled and confirmed for a user, the login pipeline's RedirectIfTwoFactorAuthenticatable step stashes the pending login in the session and redirects to the two-factor.login route instead of completing the sign-in. The challenge accepts either a TOTP code or a recovery code; on success the user is sent to the dashboard (any intended URL still wins).

Custom login pipeline — read this. LoginUserAction only inserts RedirectIfTwoFactorAuthenticatable automatically in its default pipeline. If your application defines its own pipelines.login array in config/<handle>.php (the scaffolded portal does), that array is used verbatim and you must add the step yourself — otherwise login silently completes without ever challenging, and 2FA appears to "not work". Place it before AttemptToAuthenticate:

php
// config/portal.php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Actions\Auth\Steps\RedirectIfTwoFactorAuthenticatable;

'pipelines' => [
    'login' => [
        EnsureLoginIsNotThrottled::class,
        RedirectIfTwoFactorAuthenticatable::class, // <-- required for the challenge to fire
        AttemptToAuthenticate::class,
        PrepareAuthenticatedSession::class,
    ],
],

The same applies to a callbacks.authentication closure: include the step in whatever pipeline you return.

Managing 2FA

Authenticated users manage 2FA with the TwoFactorManager component:

blade
<livewire:application.portal.auth.two-factor-manager />

It walks the user through enabling (QR code + manual key), confirming with a code, viewing/regenerating recovery codes, and disabling.

Styling. The component renders the package view qore-frontend::application.portal.livewire.auth.two-factor-manager. Override it without touching the package by creating resources/views/vendor/qore-frontend/application/portal/livewire/auth/two-factor-manager.blade.php — keep the wire:click / wire:model bindings, restyle the markup around them.

2FA management shares the same password-confirmation gate as passkeys. When fortify.confirm_password is true, the /account/security page (which embeds both managers via the ConfirmPassword middleware) requires a recent password confirmation before either can be used.

Mandatory 2FA

An application can require every user to enrol 2FA before using the portal. Enable it per application:

php
// config/portal.php
'fortify' => [
    'require_two_factor' => true,
],

The RequireTwoFactor middleware self-gates on this flag (a no-op when false), so attach it once to your authenticated route group — the scaffolded portal already adds it to the verified group:

php
use QoreWorksBusiness\QoreFrontend\Integrations\Portal\Http\Middleware\Auth\RequireTwoFactor;

Route::group(['middleware' => array_filter([
    'qore-portal-auth',
    'qore-portal-verified',
    RequireTwoFactor::class,
])], function (): void {
    // dashboard, account pages, ...
});

When enabled, an authenticated user whose two_factor_confirmed_at is null is redirected to the two-factor.setup route (registered by twoFactorRoutes()), which renders the TwoFactorManager enrolment screen. Once they confirm a code, navigation resumes. The setup, logout, challenge and password-confirm routes are intentionally not gated, so a user is never trapped. A passkey does not satisfy this requirement — TOTP enrolment is required.

Remember this device

By default a successful 2FA challenge trusts the browser for 30 days (fortify.two_factor_remember_days), so the user is not asked for a code on every login. Set the value to 0 to challenge on every login.

How it works: on a successful challenge a random token is stored hashed in the two_factor_trusted_devices table and dropped as an encrypted, HttpOnly cookie. The login pipeline (RedirectIfTwoFactorAuthenticatable) skips the challenge when a non-expired trusted-device cookie matches. Trusted devices are wiped when the user disables 2FA or resets their password (a reset may follow a compromise). Laravel's "remember me" only persists the session — it does not skip 2FA; this trusted-device mechanism is what does.