Zodiac Tech CRM Docs

Authentication

The three credential systems — web sessions, REST API keys and mobile device tokens — plus OTP flows and where each is enforced.

developer Advanced ⏱ 12 min Updated 2026-10-05

Authentication

The platform has exactly three authentication mechanisms, each for a different caller. They are deliberately independent: API keys cannot log into panels, device tokens cannot call the REST API, and sessions are never used for machine callers.

Who this is for

Developers integrating with the API, debugging login failures, or extending auth.

Difficulty: Advanced · Time: about 12 minutes.

The three mechanisms

Mechanism Credential Who uses it Middleware Where
Web session Email/phone + password via Filament login form Humans in /admin, /super-admin, /agency Filament auth + panel access checks Panel routes
API key Authorization: Bearer <key> Server-to-server integrations api.key:<scope> → App\Http\Middleware\ApiKeyAuthentication /api/v1/*
Device token Authorization: Bearer <token> The Android app App\Http\Middleware\DeviceAuthenticate /api/v1/mobile/* (except login/OTP/config)

Web sessions (Filament panels)

  • Login lives on the panel's own login page; credentials are submitted via Livewire.
  • Login lockout: 5 failed attempts → 15 minutes. Failures are recorded in login_attempts (LoginAttempt::recordFailureAndMaybeLock). Because Filament 4 submits through /livewire/update (not the panel login URL), panels enforce the lockout inside App\Filament\Auth\Pages\Login, which hooks Livewire's authenticate(). The login.lockout alias (EnforceLoginLockout) exists for any custom non-Filament login route.
  • Super Admin access additionally has the optional IP allowlist — sa.ip.allowlist → EnforceSuperAdminIpAllowlist.
  • Guests hitting an expired non-panel URL are redirected to the panel login (configured in bootstrap/app.php).

OTP login for the mobile app (pre-device-auth endpoints)

Endpoint Purpose
GET /api/v1/mobile/auth-config Which login methods this workspace supports (password/email OTP/phone OTP, Firebase options when configured)
POST /api/v1/mobile/login Email or phone + password + device_uuid → device token
POST /api/v1/mobile/otp/start Send a one-time code (email, workspace SMS, or Firebase)
POST /api/v1/mobile/otp/verify Verify code or Firebase ID token → device token

Details enforced in App\Http\Controllers\Api\Mobile\AuthController and its rate limiters:

  • OTP codes are stored HMAC-SHA256-hashed with a random salt — the plaintext is never persisted.
  • Verify attempts are capped (5 per code); issuance is limited per identifier (3 per 10 minutes) and per IP (10 per 10 minutes), on top of a coarse route throttle (throttle:10,1).
  • Phone OTP via the workspace's Firebase project is verified server-side (Google Identity Toolkit getAccountInfo) — no Admin SDK or google-services.json is required.
  • If Firebase is not configured, phone OTP falls back to the workspace's SMS provider.

API keys (REST /api/v1)

  • Keys are per-tenant credentials, managed in the CRM; the model is App\Models\ApiKey (ApiKey::findByKey($plainToken)).
  • The guard is ApiKeyAuthentication, invoked via the scope-taking alias, e.g.:
    Route::get('leads', [LeadController::class, 'index'])
        ->middleware('api.key:read:leads');
    
    Scopes follow <action>:<resource> (read:leads, write:leads, delete:leads, read:forms, …). A missing scope → 403 Insufficient scope. Required: ….
  • The key must be active and its workspace subscription must be usable: TenantSubscriptionState::of($tenant)->isActive() (on-trial or active-paid). Otherwise the API returns 403 even with a valid key.
  • On success the middleware binds app('current_tenant') and tenant_id so the tenant scope works for the whole request.
  • Rate limits are enforced in two tiers with response headers (X-RateLimit-Limit, -Remaining, -Reset):
    • per key: api_keys.rate_limit_per_hour (fixed hourly window);
    • per tenant: tenants.settings.api.rate_limit_per_hour override, else config('leadhub.api.tenant_rate_limit_multiplier') × per-key (default 5×). Exceeding either returns 429 with Retry-After.
  • Public health check: GET /api/v1/health. The interactive spec is at /api/docs.

Device tokens (mobile /api/v1/mobile)

Per-user, per-device credentials minted at login:

  1. The app posts POST /api/v1/mobile/login (or otp/verify) with email|phone, password/code and device_uuid.
  2. The server registers/updates the row in devices and mints a token; only device_tokens.token_hash is stored. Tokens live up to 90 days (TOKEN_TTL_DAYS = 90).
  3. Every subsequent request carries Authorization: Bearer <token> through DeviceAuthenticate, which:
    • looks the token up without the tenant scope (the lookup must span tenants), matching DeviceToken::hashToken($plain);
    • rejects unusable/expired/revoked tokens and suspended users with the mobile error envelope {"error":{"message","code":"UNAUTHORIZED"}};
    • resolves the device's user and tenant, then binds auth()->user(), the user resolver, and app('current_tenant') so tenant scoping and per-user lead restriction behave exactly like the panel;
    • debounces last_used_at / last_seen_at writes to at most once per minute per device.
  4. POST /api/v1/mobile/logout revokes the token for that device. device/heartbeat and device/register keep device metadata fresh (app version, push token).

Route throttles: login, otp/start, otp/verify each have throttle:10,1 at the route level; in-controller limiters add the per-identifier/per-IP backstops above.

Where each is enforced (quick reference)

Control Class
REST key guard App\Http\Middleware\ApiKeyAuthentication (alias api.key)
Mobile device guard App\Http\Middleware\DeviceAuthenticate
Panel lockout App\Filament\Auth\Pages\Login + App\Models\LoginAttempt
Custom-route lockout App\Http\Middleware\EnforceLoginLockout (alias login.lockout)
SA IP allowlist App\Http\Middleware\EnforceSuperAdminIpAllowlist (alias sa.ip.allowlist)
Tenant context for web App\Http\Middleware\ResolveTenant (+ EnforceTenantScope via tenant.scope)
Subscription gate App\Services\TenantSubscriptionState / EnforceSubscription

Was this page helpful?