Skip to content

Authentication and users

laravel-auth 6.1 handles authentication: it answers JSON under /auth, in the web group. The SPA screens are just the face. The user is a model generated by LaraPack, and ADMIN_EMAILS decides who administers.

The session

The SPA uses no tokens: it signs in with Sanctum's session cookie.

  • bootstrap/app.php calls statefulApi().
  • axios sends withCredentials and withXSRFToken (since axios 1.6, X-XSRF-TOKEN is not sent without it).
  • Before a POST that regenerates the session (log in, register), it requests GET /sanctum/csrf-cookie.
  • A 419 (expired CSRF token) fetches a new cookie and retries the request once.
  • A 401 outside the session load clears the session and goes to /auth/login?redirect=<route>.

At boot the interface requests auth.get.auth and stores:

json
{
    "user": { "id": 1, "name": "Ana", "email": "ana@example.com", "payload": { "avatar": "/lu/upload/…" } },
    "authenticated": true,
    "is_admin": true,
    "verified": true,
    "impersonating": false
}
FieldWhere it comes from
authenticatedThere is a user under the Sanctum guard
is_admin$user->isAdmin()
verifiedtrue if the user does not implement MustVerifyEmail or has verified their email
impersonatingThe session has impersonate_token

APP_URL and the session domains

If the cookie does not arrive, login answers fine but get-auth says there is no session. Check the common problems in Install.

The flows

Log in

/auth/login (guests only).

  1. GET the CSRF cookie.
  2. POST auth.login with { email, password, remember }.
  3. load() the session.
  4. Navigate to ?redirect= if it is an internal path (starts with /, not // or /\). Otherwise to /admin.

A 422 shows under each field. A 429 says "Too many attempts". In Vue, if the POST succeeded but the session did not stick, it says "You signed in, but the session was not kept. Check the session and Sanctum domains.". In React, the /admin guard sends the user back to login.

Register

/auth/register (guests only). It sends { name, email, password, password_confirmation } to auth.register after fetching the CSRF cookie, and loads the session. Then it navigates to ?redirect= or to /admin.

  • With allow-registration set to false in config/laravel-auth.php, laravel-auth answers 403 and the screen says "New accounts are not being accepted right now.".
  • The password rules are in that config's password key: length (8), uppercase (false) and number (false).

Log out

POST auth.logout. The local session is cleared even if the request fails.

Password reset

  1. /auth/forgot-password sends { email } to auth.forgot.password. laravel-auth answers the same whether or not the account exists, and the screen shows that message.
  2. The email links to laravel-auth's frontend.reset-password: auth/reset-password/{token}/{email}, with the email URL-encoded. That is the SPA route /auth/reset-password/:token/:email.
  3. That screen sends { token, email, password, password_confirmation } to auth.reset.password and goes to login with a toast.

In Vue the email comes from the URL and is read-only. In React it can be edited. React also fetches the CSRF cookie before both POSTs.

Email verification

  • With a session and verified === false, the admin panel shows a notice with "Resend the email", which calls auth.email.verification.notification (at most 6 per minute).
  • If it answers status: 'already-verified', the interface reloads the session.
  • The email link goes to laravel-auth's route auth/email/verify/{id}/{hash} (signed, session required), which redirects to /admin.

Verification is not required by default

The User LaraPack generates does not implement MustVerifyEmail, so verified is always true and the notice never shows. To require verification, implement Illuminate\Contracts\Auth\MustVerifyEmail in app/Models/User.php. LaraPack keeps the file when regenerating with --force, because its hash no longer matches.

Change password

From the profile: POST auth.update.password with { old_password, password, password_confirmation }. With laravel-auth 6.0.3 or later, changing it does not sign you out of the admin panel.

Impersonation

An admin can sign in as another user to see what they see.

Who can. The laravel-auth.impersonate Gate ability. By default: a user with isAdmin(), never on themselves or another admin. Turn it off entirely with allow-impersonate set to false, and redefine it in your AuthServiceProvider or with ImpersonateRequest::authorizeUsing().

The flow:

  1. POST auth.impersonate with { target_user_id }{ token, url }. The token encrypts who asked, for whom and when.
  2. GET url (auth/impersonate/{token}): signs in as the user, regenerates the session and stores impersonate_token. If the request does not expect JSON, it redirects to /admin. The token is valid for two minutes and only for whoever asked for it.
  3. get-auth answers impersonating: true and the admin panel shows "You are viewing the account of <name>" with "Back to my account".
  4. "Back to my account" does a POST to auth.revert.impersonate and reloads the session. Vue goes to /admin, or to login if the session was closed; React goes to /admin.
  5. An impersonation lasts at most two hours. After that, reverting signs out instead of restoring the original account.

Reverting is a POST since laravel-auth 6.1

As a GET, another site could end an impersonation with an <img> and no CSRF token. laravel-auth 6.1.0 only accepts POST, and laravel-setup 7.0.1 uses it in both interfaces. That is why the application requires innoboxrr/laravel-auth ^6.1.0: with 6.0 that POST would answer 405.

There is no button to start

The interfaces only include the notice and "Back to my account". To start an impersonation, call the endpoint yourself, for example from an action in the users table:

js
import { apiUrl, http } from '@app/http.js'

const { data } = await http.post(apiUrl('auth.impersonate'), { target_user_id: 42 })

// Full navigation: laravel-auth signs in as the user and redirects to /admin.
window.location.assign(data.url)
js
import axios from 'axios'
import route from 'innoboxrr-route-resolver'

const { data } = await axios.post(route('auth.impersonate'), { target_user_id: 42 })

// Full navigation: laravel-auth signs in as the user and redirects to /admin.
window.location.assign(data.url)

Who administers: ADMIN_EMAILS

text
.env                ADMIN_EMAILS=ana@example.com,luis@example.com

config/auth.php     'admins' => array_values(array_filter(array_map('trim', explode(',', (string) env('ADMIN_EMAILS', '')))))

User::isAdmin()     compares the email, case-insensitively

The isAdmin() LaraPack generates with authenticatable:

php
public function isAdmin(): bool
{
    $admins = array_map('strtolower', (array) config('auth.admins', []));

    return in_array(strtolower((string) $this->email), $admins, true);
}

Who reads it:

WhereWhat it decides
auth.get.authis_adminThe "Administration" group, adminOnly and /admin/site in the interface
before() in the policies LaraPack generatesAn admin passes everything except forceDelete
The base application's UserPolicyThe same
laravel-options policyOnly an admin writes options (the site)
admin middleware (EnsureUserIsAdmin)/env-editor
viewLogViewer gate/log-viewer
laravel-auth.impersonate gateWho can impersonate
  • With the configuration cached (php artisan config:cache), a change to ADMIN_EMAILS does not show until php artisan config:clear, or until you cache it again.
  • The interface reads is_admin at boot: reload the page.
  • ADMIN_EMAILS is a starting point. For roles, change isAdmin() in app/Models/User.php: everything in the table keeps working.

The generated user

laraimport.json, as app:setup leaves it:

json
{
    "$schema": "vendor/innoboxrr/larapack-generator/schema/laraimport.schema.json",
    "models": [
        {
            "name": "User",
            "authenticatable": true,
            "metas": true,
            "editable_metas": ["avatar"],
            "routes": { "except": ["create"] },
            "props": [
                { "name": "name", "type": "string", "datatable": true, "form": true, "form_component": "TextInputComponent", "form_submit": true },
                { "name": "email", "type": "string", "datatable": true, "form": true, "form_component": "TextInputComponent", "form_submit": true },
                { "name": "email_verified_at", "type": "timestamp", "nullable": true, "fillable": false, "creatable": false, "updatable": false, "datatable": true },
                { "name": "password", "type": "string", "updatable": false, "exports_cols": false }
            ],
            "requests": [
                {
                    "name": "Update",
                    "rules": {
                        "user_id": "required|numeric",
                        "name": "sometimes|required|string|max:255",
                        "email": "sometimes|required|email|max:255"
                    }
                }
            ]
        }
    ]
}
DeclarationWhat it generates
authenticatable: trueThe model extends Illuminate\Foundation\Auth\User with Notifiable and HasApiTokens; hides password and remember_token; casts email_verified_at to a date and password to hashed; and adds isAdmin().
metas: true, editable_metas: ["avatar"]The user's metas table, its UserMeta model, the metas() relation and the copy in payload. The form only writes the avatar meta.
routes.except: ["create"]No create endpoint or form: accounts are born at registration. Index, show, update, delete, restore, forceDelete, export and the bulk actions remain.
requestsThe update rules: the profile sends name and email separately from avatar, hence the sometimes.
  • The API lives in routes/api/models/ with api.app.user.* names.
  • The interface module has the AdminUsers route, which is in adminOnly.
  • Laravel creates the users table, so LaraPack does not alter it. The base application's 0001_01_01_000010_add_payload_and_soft_deletes_to_users_table.php migration adds payload and deleted_at.

How to declare and regenerate a model is in The contract: laraimport.json.

The base application's UserPolicy

The policy LaraPack generates is closed by default: only the admin passes. The profile needs each person to view and edit their own account, so app:setup copies this policy over it:

AbilityAdminThe user themselvesAnother user
index, viewAnyYesNoNo
viewYesYesNo
updateYesYesNo
createYesNoNo
delete, restore, exportYesNoNo
forceDeleteNoNoNo

forceDelete is in before()'s $exceptAbilities: permanent deletion is off by default, even for admins. LaraPack keeps the file when regenerating with --force, because its hash does not match the manifest's, and larapack:verify reports it as customised.

The profile

/admin/profile, for any signed-in user.

CardRequest
Account (name and email)PUT api.app.user.update with { user_id, name, email }, then reloads the session
Photo: uploadPOST lu.upload.file (multipart with file and visibility=public); then PUT api.app.user.update with { user_id, avatar: <uri> }
Photo: removePUT api.app.user.update with { user_id, avatar: '' }. An empty meta is deleted.
PasswordPOST auth.update.password with { old_password, password, password_confirmation }
  • What is stored. The upload's relative uri (the public lu.upload.display route), not the absolute url: it keeps working if the domain changes.
  • How it is read. As payload.avatar, whether payload arrives as an object or as JSON text. Without a photo, or if it fails to load in React, the initials show.
  • Which files. Images only (accept="image/*", checked before uploading). Size and type limits are laravel-uploads', which does not allow SVG. The disk is LARAVEL_UPLOADS_DISK, which the base application sets to public.
  • Why not AvatarInputComponent. In Vue the upload goes through axios because that component uses fetch without the XSRF header, and the laravel-uploads route requires it with the session.
  • Where the code is. In Vue, the calls are in admin/ProfileView.vue. In React, in the session store: updateProfile, updateAvatar and removeAvatar.

Social login

Not wired in the interfaces

app:setup writes VITE_GOOGLE_LOGIN, VITE_FACEBOOK_LOGIN and VITE_MICROSOFT_LOGIN, but neither the Vue nor the React interface reads any VITE_* variable, and no screen has provider buttons.

laravel-auth does include the Socialite routes: auth.socialite.redirect (auth/social/{provider}/redirect) and auth.socialite.callback, which redirects to /admin. Providers are configured in config/services.php. Wiring it means adding the buttons to the authentication screens yourself; see laravel-auth.