diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73dd8d44..eb6ecd72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: run: composer test:unit - name: Generate Coverage Baseline + timeout-minutes: 30 + env: + COMPOSER_PROCESS_TIMEOUT: '0' run: composer coverage:baseline - name: Upload Coverage Baseline diff --git a/RELEASE.md b/RELEASE.md index 86343070..43a03b15 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,31 +1,42 @@ -# v1.6.64 — Order reporting and test SMS with the entered credentials +# v1.6.65 — Authenticator-app 2FA, sign-in hardening and IAM permission fixes -## Improvements for reporting +## Improvements -- Declare row-level expression columns with `Column::expression($name, $sql, $type)`. Bare names resolve against the table or relationship that declares it, so `JSON_EXTRACT(meta, '$.quantity')` on `payload.entities` reads the joined entity's `meta`. An expression column can be selected, filtered, sorted, grouped by and aggregated. -- Summary columns (`Column::count/sum/avg`) are flagged `aggregate` and resolve to their computation. Without grouping they return a single summary row; with grouping they sit beside the group keys. -- `Table::softDeletes()` and `Relationship::softDeletes()` leave out soft-deleted rows. On joins the filter goes in the `ON` clause, so LEFT joins keep the parent row. -- Computed columns can be group keys, conditions and sort columns, and a grouped report can be sorted by an aggregate's alias. A new `count_distinct` aggregate is available. -- Custom expressions accept the JSON functions, `DATE()`, `CAST(… AS DECIMAL(15,2))` and the other cast types, `DISTINCT`, `IN`, `GROUP_CONCAT(… ORDER BY … SEPARATOR …)`, `->`/`->>` and `INTERVAL n UNIT`. -- `public_id` and `internal_id` are no longer hidden as foreign keys, so ID columns appear in the column picker. -- Relationship columns are labelled with the whole relationship name ("Order Config Namespace", not "Order Namespace"), and aggregate labels use the column label ("Sum (Quantity)"). -- `_key` and `_import_id` are never listed or selectable, whatever a schema declares. +- Organization administration supports company and owner identity search, country/timezone/owner-IP and registration/update-date filters, and sorting by current user count. Admins can open organizations outside their own memberships, inspect organization-scoped usage totals, and see members' 2FA methods and linked OAuth providers without exposing authentication secrets. +- Sign in with an authenticator app (TOTP, RFC 6238), next to email and SMS 2FA (#163). It works with Authy, Google Authenticator, Microsoft Authenticator and 1Password. + - New `users/two-fa/authenticator` endpoints to set up, confirm, disable and inspect the app. Setup, disable and regenerating recovery codes need the current password. + - Confirming the app returns 8 single-use recovery codes. `two-fa/verify` accepts an app code (±1 step for clock drift, each code once) or a recovery code. + - `two-fa/resend` falls back to an emailed (or SMS) code and returns the new `method`. + - The secret is encrypted with the app key, and recovery codes are stored as keyed hashes. + - New `Fleetbase\Support\Barcode` helper with a compact `qrCodeSvg()`. +- Record the app, APNs environment and last-seen time on user devices. New nullable `user_devices` columns: `app_identifier` (indexed), `environment` and `last_seen_at`. +- New organization setting to let users change their own password (default on), at `GET/POST companies/auth-settings`. `GET users/password-policy` returns `{can_change_password}`. ## Fixes -- Test SMS Provider and Test Twilio in Admin › System Config › Services use the credentials entered in the form (fleetbase/fleetbase#680). Under Octane the Twilio client was built once per worker, so a test failed with "Credentials are required to create a Client" or reported success for the saved account. The endpoints now rebuild the client from the request's config and release it after the send. +- Settings → Notifications applies when notifications are sent from the queue or a console command (#262). `NotificationRegistry` takes the company from the notification's subject instead of the session. `notifyUsingDefinitionName()` reads the company-scoped settings instead of a global key nothing writes. New `Setting::lookupForCompany()`. +- Invited users can set their first password, and non-admins can change their own (#263). The password endpoints no longer fall through to `iam create user`. +- FCM order notifications are sent with Android `priority: high`, so drivers' phones in Doze get them immediately (#268). ## Security -- Every computed column is validated up front, including in grouped reports. Names must be safe identifiers, and `SELECT` is forbidden. -- Schema-declared columns always take their SQL from the registry, never from the request. Group keys, aggregate columns, sort columns and condition fields must be allowed or computed columns. -- Sort direction is normalised to `asc`/`desc`, and grouped reports validate `aggregateBy.computation`. +- A 2FA session starts only after the password is checked. `GET two-fa/check` used to start one from the identity alone, so an email plus the emailed code was enough to sign in. It now always returns `{twoFaSession: null, isTwoFaEnabled: false}` and no longer reveals whether an account uses 2FA. +- 2FA sessions expire after 10 minutes; they used to live about 56 years. The 5th wrong code deletes the session, and resending doesn't reset the count. Codes are compared in constant time and generated with `random_int`. +- `users/change-password` requires `current_password` in the same request, and `iam change-password` is enforced. `users/set-password` works only once, within 24 hours of accepting an invite. `validate-password` and `change-password` are limited to 10 requests per minute. +- Close authorization gaps where `AuthorizationGuard` resolved to permission names that don't exist: + - Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore `owner_uuid`, Stripe ids, `plan`, `status`, `trial_ends_at` and `type`. + - `POST two-fa/config` (system 2FA policy) and admin platform metrics are limited to system admins. + - IAM and developer metrics need `iam list user` / `developers list api-key`. + - Reports use the `iam` service: `iam execute report` for direct queries, `iam export report` for exports. + - API credentials, webhooks, API events and request logs check the `api-key`, `webhook`, `event` and `log` permissions. +- Password, auth-setting and authenticator changes are written to the `auth` activity log. ## Behaviour changes -- In a grouped report, a selected column that is neither a group key nor aggregated is now an error instead of being dropped. -- Invalid report shapes fail with a clear message instead of an SQL error. +- Consoles need the companion fleetbase/fleetbase changes: the 2FA sign-in flow (`fix/2fa-login-hardening`), `current_password` on change-password (fleetbase/fleetbase#685) and the authenticator-app UI (fleetbase/fleetbase#686). With an older console, users with 2FA can't sign in and self-service password changes fail. +- Users need `iam … report` permissions to use reports, and `developers …` permissions for API keys, webhooks, events and logs. Fleet-Ops' report screens check the same names in fleetbase/fleetops#345. +- A user who accepted an invite before this release but never set a password should use **Forgot password**. -A database migration is not required. No configuration change is needed. The FleetOps order report schema ships in fleetbase/fleetops v0.6.70, and the report builder changes in fleetbase/ember-ui v0.4.4. +Run the migrations for the new `user_devices` columns. Run `composer update` to install `pragmarx/google2fa`. -Changes: [#269](https://github.com/fleetbase/core-api/pull/269), [#270](https://github.com/fleetbase/core-api/pull/270). +Changes: [#272](https://github.com/fleetbase/core-api/pull/272), [#273](https://github.com/fleetbase/core-api/pull/273), [#274](https://github.com/fleetbase/core-api/pull/274), [#275](https://github.com/fleetbase/core-api/pull/275), [#276](https://github.com/fleetbase/core-api/pull/276), [#277](https://github.com/fleetbase/core-api/pull/277), [#278](https://github.com/fleetbase/core-api/pull/278). diff --git a/composer.json b/composer.json index 5ef971d4..0978d23f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.64", + "version": "1.6.65", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -46,9 +46,11 @@ "lcobucci/clock": "3.3.1", "lcobucci/jwt": "^5.4", "maatwebsite/excel": "^3.1", + "milon/barcode": "^10.0", "mossadal/math-parser": "^1.3", "phpoffice/phpspreadsheet": "^1.28", "phrity/websocket": "^1.7", + "pragmarx/google2fa": "^8.0", "rlanvin/php-rrule": "^2.4", "sentry/sentry-laravel": "*", "spatie/laravel-activitylog": "^4.7", diff --git a/migrations/2026_09_26_000000_add_push_metadata_to_user_devices_table.php b/migrations/2026_09_26_000000_add_push_metadata_to_user_devices_table.php new file mode 100644 index 00000000..e1e00600 --- /dev/null +++ b/migrations/2026_09_26_000000_add_push_metadata_to_user_devices_table.php @@ -0,0 +1,46 @@ +string('app_identifier', 191)->nullable()->index()->after('platform'); + } + + if (!Schema::hasColumn('user_devices', 'environment')) { + $table->string('environment', 32)->nullable()->after('app_identifier'); + } + + if (!Schema::hasColumn('user_devices', 'last_seen_at')) { + $table->timestamp('last_seen_at')->nullable()->after('status'); + } + }); + } + + public function down(): void + { + Schema::table('user_devices', function (Blueprint $table) { + foreach (['app_identifier', 'environment', 'last_seen_at'] as $column) { + if (Schema::hasColumn('user_devices', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/src/Http/Controllers/Internal/v1/AdminMetricsController.php b/src/Http/Controllers/Internal/v1/AdminMetricsController.php index 65404844..0dfafd78 100644 --- a/src/Http/Controllers/Internal/v1/AdminMetricsController.php +++ b/src/Http/Controllers/Internal/v1/AdminMetricsController.php @@ -6,6 +6,7 @@ use Fleetbase\Models\Activity; use Fleetbase\Models\Company; use Fleetbase\Models\User; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -14,6 +15,18 @@ class AdminMetricsController extends Controller { + public function __construct() + { + // Platform-wide figures: system administrators only. + $this->middleware(function ($request, $next) { + if (!Auth::getUserFromSession($request)?->isAdmin()) { + return response()->error('Only system administrators can view platform metrics.', 401); + } + + return $next($request); + }); + } + public function kpi(Request $request, string $slug): JsonResponse { [$currentPeriodStart, $previousPeriodStart] = $this->periodBoundaries(); diff --git a/src/Http/Controllers/Internal/v1/ApiCredentialController.php b/src/Http/Controllers/Internal/v1/ApiCredentialController.php index 4d482114..9e9c64b4 100644 --- a/src/Http/Controllers/Internal/v1/ApiCredentialController.php +++ b/src/Http/Controllers/Internal/v1/ApiCredentialController.php @@ -33,6 +33,11 @@ class ApiCredentialController extends FleetbaseController */ public $service = 'developers'; + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'api-key'; + /** * Create a new API credential record. * diff --git a/src/Http/Controllers/Internal/v1/ApiEventController.php b/src/Http/Controllers/Internal/v1/ApiEventController.php index fcf4eb51..b73ffcb0 100644 --- a/src/Http/Controllers/Internal/v1/ApiEventController.php +++ b/src/Http/Controllers/Internal/v1/ApiEventController.php @@ -19,4 +19,9 @@ class ApiEventController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'event'; } diff --git a/src/Http/Controllers/Internal/v1/ApiRequestLogController.php b/src/Http/Controllers/Internal/v1/ApiRequestLogController.php index bb3ef65f..a2f85852 100644 --- a/src/Http/Controllers/Internal/v1/ApiRequestLogController.php +++ b/src/Http/Controllers/Internal/v1/ApiRequestLogController.php @@ -19,4 +19,9 @@ class ApiRequestLogController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'log'; } diff --git a/src/Http/Controllers/Internal/v1/CompanyController.php b/src/Http/Controllers/Internal/v1/CompanyController.php index 14f42b4d..f9baaffa 100644 --- a/src/Http/Controllers/Internal/v1/CompanyController.php +++ b/src/Http/Controllers/Internal/v1/CompanyController.php @@ -2,6 +2,7 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Attributes\SkipAuthorizationCheck; use Fleetbase\Events\UserRemovedFromCompany; use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Exports\CompanyExport; @@ -14,8 +15,10 @@ use Fleetbase\Models\CompanyUser; use Fleetbase\Models\ExtensionInstall; use Fleetbase\Models\Invite; +use Fleetbase\Models\Setting; use Fleetbase\Models\User; use Fleetbase\Support\Auth; +use Fleetbase\Support\OrganizationAdminSummary; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -32,6 +35,26 @@ class CompanyController extends FleetbaseController */ public $resource = 'company'; + /** + * Company attributes only platform administrators may change through updateRecord(). + */ + private const PLATFORM_MANAGED_FIELDS = ['owner_uuid', 'stripe_customer_id', 'stripe_connect_id', 'plan', 'status', 'trial_ends_at', 'type']; + + public function __construct() + { + parent::__construct(); + + // Organization settings and the organization's 2FA policy: owner, Administrator role or system admin. + $this->middleware(function ($request, $next) { + $company = Company::where('uuid', session('company'))->first(); + if (!$company || !$this->currentUserManagesOrganization($company)) { + return response()->error('Only the organization owner or an Administrator can change organization settings.', 401); + } + + return $next($request); + })->only(['updateRecord', 'saveTwoFactorSettings']); + } + /** * Find an organization visible to the current session company. * @@ -39,7 +62,7 @@ class CompanyController extends FleetbaseController */ public function findRecord(Request $request, $id) { - $company = $this->resolveVisibleCompany($id); + $company = $this->resolveVisibleCompanyForUsers($id, $request); if (!$company) { return response()->error('Organization not found.', 404); @@ -63,6 +86,11 @@ public function updateRecord(Request $request, string $id) try { $input = $this->model->getApiPayloadFromRequest($request); + + // Ownership moves through transferOwnership(); billing and lifecycle fields are platform-managed. + if (!$request->user()?->isAdmin()) { + $input = Arr::except($input, self::PLATFORM_MANAGED_FIELDS); + } $input = $this->model->fillSessionAttributes($input, [], ['updated_by_uuid']); if ($this->model->isColumn('slug')) { @@ -157,6 +185,7 @@ public function saveTwoFactorSettings(Request $request) if (!$company) { return response()->error('No company session found', 401); } + if (isset($twoFaSettings['enabled']) && $twoFaSettings['enabled'] === false) { $twoFaSettings['enforced'] = false; } @@ -165,6 +194,63 @@ public function saveTwoFactorSettings(Request $request) return response()->json(['message' => 'Two-Factor Authentication saved successfully']); } + /** + * Get the current organization's authentication settings. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function getAuthSettings() + { + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('No company session found', 401); + } + + return response()->json(Auth::getCompanyAuthSettings($company->uuid)); + } + + /** + * Save the current organization's authentication settings. Only admins and users + * holding the Administrator role may change them. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function saveAuthSettings(Request $request) + { + $user = $request->user(); + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('No company session found', 401); + } + + if (!$user || !($user->isAdmin() || $user->hasRole('Administrator'))) { + return response()->error('Only administrators can change authentication settings.', 403); + } + + if (!$request->has('allow_users_change_password')) { + return response()->error('No authentication settings provided.', 422); + } + + $settings = array_merge(Auth::getCompanyAuthSettings($company->uuid), [ + 'allow_users_change_password' => $request->boolean('allow_users_change_password'), + ]); + + Setting::configure('company.' . $company->uuid . '.auth', $settings); + + activity('auth') + ->causedBy($user) + ->performedOn($company) + ->withProperties($settings) + ->event('auth_settings_updated') + ->log('Authentication settings updated'); + + return response()->json($settings); + } + /** * Get all users for a company. * @@ -213,6 +299,10 @@ public function users(string $id, Request $request) // replace in pagination $users->setCollection($transformedItems); + if ($request->user()?->isAdmin()) { + OrganizationAdminSummary::attachAuthentication($transformedItems); + } + return response()->json([ 'users' => UserResource::collection($users->getCollection()), 'meta' => [ @@ -237,9 +327,23 @@ public function users(string $id, Request $request) return $companyUser->user; }); + if ($request->user()?->isAdmin()) { + OrganizationAdminSummary::attachAuthentication($users); + } + return UserResource::collection($users); } + public function usage(string $id, AdminRequest $request): JsonResponse + { + $company = $this->resolveAdminCompany($id); + if (!$company) { + return response()->json(['error' => 'Organization not found.'], 404); + } + + return response()->json(['usage' => OrganizationAdminSummary::usage($company)]); + } + private function resolveVisibleCompanyForUsers(string $id, Request $request): ?Company { $user = $request->user(); @@ -261,6 +365,26 @@ private function resolveVisibleCompanyForUsers(string $id, Request $request): ?C ->first(); } + /** + * Whether the session user may manage the organization: platform admins, the owner, + * and members holding the Administrator role in it. + */ + private function currentUserManagesOrganization(Company $company): bool + { + $user = Auth::getUserFromSession(); + if (!$user) { + return false; + } + + if ($user->isAdmin() || $company->owner_uuid === $user->uuid) { + return true; + } + + $companyUser = CompanyUser::where('company_uuid', $company->uuid)->where('user_uuid', $user->uuid)->first(); + + return $companyUser !== null && $companyUser->roles()->where('name', 'Administrator')->exists(); + } + private function resolveVisibleCompany(string $id): ?Company { $sessionCompany = session('company'); diff --git a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php index 6add50a9..4eb3baf1 100644 --- a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php +++ b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php @@ -8,12 +8,25 @@ use Fleetbase\Models\ApiRequestLog; use Fleetbase\Models\WebhookEndpoint; use Fleetbase\Models\WebhookRequestLog; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; class DeveloperMetricsController extends Controller { + public function __construct() + { + // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it. + $this->middleware(function ($request, $next) { + if (Auth::cannotUnlessAdmin('developers list api-key')) { + return response()->error('User is not authorized to list api-key', 401); + } + + return $next($request); + }); + } + public function kpis(Request $request): JsonResponse { [$start, $end, $previousStart, $previousEnd] = $this->periods($request); diff --git a/src/Http/Controllers/Internal/v1/IamMetricsController.php b/src/Http/Controllers/Internal/v1/IamMetricsController.php index 27a75a9d..e8208812 100644 --- a/src/Http/Controllers/Internal/v1/IamMetricsController.php +++ b/src/Http/Controllers/Internal/v1/IamMetricsController.php @@ -11,6 +11,7 @@ use Fleetbase\Models\Role; use Fleetbase\Models\Setting; use Fleetbase\Models\User; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -21,6 +22,18 @@ class IamMetricsController extends Controller { private const DORMANT_DAYS = 90; + public function __construct() + { + // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it. + $this->middleware(function ($request, $next) { + if (Auth::cannotUnlessAdmin('iam list user')) { + return response()->error('User is not authorized to list user', 401); + } + + return $next($request); + }); + } + public function kpis(Request $request): JsonResponse { $companyUuid = session('company'); diff --git a/src/Http/Controllers/Internal/v1/ReportController.php b/src/Http/Controllers/Internal/v1/ReportController.php index 7cb7f29d..1693292c 100644 --- a/src/Http/Controllers/Internal/v1/ReportController.php +++ b/src/Http/Controllers/Internal/v1/ReportController.php @@ -2,8 +2,10 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Attributes\SkipAuthorizationCheck; use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Models\Report; +use Fleetbase\Support\Auth; use Fleetbase\Support\Reporting\ComputedColumnValidator; use Fleetbase\Support\Reporting\ReportQueryConverter; use Fleetbase\Support\Reporting\ReportQueryErrorHandler; @@ -22,12 +24,30 @@ class ReportController extends FleetbaseController */ public $resource = 'report'; + /** + * The IAM schema service for report permissions (`iam list report`, `iam execute report`, ...). + * + * @var string + */ + public $service = 'iam'; + protected ReportQueryValidator $queryValidator; protected ReportQueryErrorHandler $errorHandler; public function __construct() { parent::__construct(); + + // These endpoints skip AuthorizationGuard (their method names do not map to a schema action). + foreach (['execute' => ['executeQuery'], 'export' => ['exportQuery', 'download']] as $action => $methods) { + $this->middleware(function ($request, $next) use ($action) { + if (Auth::cannotUnlessAdmin("iam {$action} report")) { + return response()->error("User is not authorized to {$action} report", 401); + } + + return $next($request); + })->only($methods); + } $this->queryValidator = new ReportQueryValidator(app(ReportSchemaRegistry::class)); $this->errorHandler = new ReportQueryErrorHandler(); } @@ -238,6 +258,7 @@ public function execute(Request $request, string $id): JsonResponse /** * Execute a query directly without saving as report. */ + #[SkipAuthorizationCheck] public function executeQuery(Request $request): JsonResponse { try { @@ -368,6 +389,7 @@ public function export(Request $request, string $id): JsonResponse /** * Export query results directly without saving as report. */ + #[SkipAuthorizationCheck] public function exportQuery(Request $request): JsonResponse { try { @@ -431,6 +453,7 @@ public function exportQuery(Request $request): JsonResponse /** * Download exported file. */ + #[SkipAuthorizationCheck] public function download(Request $request, string $filename) { try { diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php index f81c7d58..7fb7f632 100644 --- a/src/Http/Controllers/Internal/v1/TwoFaController.php +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -15,6 +15,18 @@ */ class TwoFaController extends Controller { + public function __construct() + { + // The system-wide 2FA policy applies to every organization: system administrators only. + $this->middleware(function ($request, $next) { + if (!Auth::getUserFromSession($request)?->isAdmin()) { + return response()->error('Only system administrators can change the system two-factor policy.', 401); + } + + return $next($request); + })->only('saveSystemConfig'); + } + /** * Save Two-Factor Authentication system wide settings. * @@ -44,19 +56,20 @@ public function getSystemConfig() } /** - * Check Two-Factor Authentication status for a given user identity. + * Retained for older consoles, which call this before submitting the password. + * + * It used to start a 2FA session from the identity alone, which let the emailed/SMS + * code stand in for the password and revealed which accounts have 2FA enabled. A 2FA + * session is now only started by `auth/login` once the password checks out, so this + * always reports 2FA as off and older consoles continue to the password login. * * @return \Illuminate\Http\Response */ public function checkTwoFactor(Request $request) { - $identity = $request->input('identity'); - $twoFaSession = TwoFactorAuth::createTwoFaSessionIfEnabled($identity); - $isTwoFaEnabled = $twoFaSession !== null; - return response()->json([ - 'twoFaSession' => $twoFaSession, - 'isTwoFaEnabled' => $isTwoFaEnabled, + 'twoFaSession' => null, + 'isTwoFaEnabled' => false, ]); } @@ -76,6 +89,7 @@ public function validateSession(TwoFaValidationRequest $request) return response()->json([ 'clientToken' => $validClientToken, + 'method' => TwoFactorAuth::getChallengeMethod($identity, $validClientToken), 'expired' => false, ]); } catch (\Exception $e) { @@ -137,6 +151,7 @@ public function resendCode(Request $request) return response()->json([ 'clientToken' => $clientToken, + 'method' => TwoFactorAuth::getChallengeMethod($identity, $clientToken), ]); } catch (\Exception $e) { return response()->error($e->getMessage()); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 2dc94412..1f2fa2c0 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -10,6 +10,7 @@ use Fleetbase\Http\Requests\CreateUserRequest; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\Internal\AcceptCompanyInvite; +use Fleetbase\Http\Requests\Internal\ChangeCurrentPasswordRequest; use Fleetbase\Http\Requests\Internal\ChangeCurrentUserEmailRequest; use Fleetbase\Http\Requests\Internal\ChangeUserEmailRequest; use Fleetbase\Http\Requests\Internal\InviteUserRequest; @@ -711,11 +712,113 @@ public function saveTwoFactorSettings(Request $request) return response()->error('No user session found', 401); } + if (($twoFaSettings['method'] ?? null) === TwoFactorAuth::METHOD_AUTHENTICATOR_APP && !TwoFactorAuth::hasAuthenticatorApp($user)) { + return response()->error('Set up your authenticator app before choosing it as your two-factor method.', 422); + } + $twoFaSettings = TwoFactorAuth::saveTwoFaSettingsForUser($user, $twoFaSettings); return response()->json($twoFaSettings->value); } + /** + * Get the current user's authenticator app status. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function getAuthenticatorApp(Request $request) + { + return response()->json(TwoFactorAuth::getAuthenticatorStatus($request->user())); + } + + /** + * Start setting up an authenticator app for the current user. Requires the current + * password, since it changes how the user signs in. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function setupAuthenticatorApp(Request $request) + { + $user = $request->user(); + if (!$user->checkPassword((string) $request->input('password'))) { + return response()->error('The current password provided is invalid.', 422); + } + + return response()->json(TwoFactorAuth::beginAuthenticatorEnrollment($user)); + } + + /** + * Confirm the current user's new authenticator app with a code from it. Returns the + * recovery codes, which are only shown this once. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function confirmAuthenticatorApp(Request $request) + { + $user = $request->user(); + + try { + $recoveryCodes = TwoFactorAuth::confirmAuthenticatorEnrollment($user, (string) $request->input('code')); + } catch (\Exception $e) { + return response()->error($e->getMessage(), 422); + } + + return response()->json([ + 'recovery_codes' => $recoveryCodes, + 'status' => TwoFactorAuth::getAuthenticatorStatus($user), + 'settings' => TwoFactorAuth::getTwoFaSettingsForUser($user)->value, + ]); + } + + /** + * Remove the current user's authenticator app. Requires the current password. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function disableAuthenticatorApp(Request $request) + { + $user = $request->user(); + if (!$user->checkPassword((string) $request->input('password'))) { + return response()->error('The current password provided is invalid.', 422); + } + + TwoFactorAuth::disableAuthenticatorApp($user); + + return response()->json([ + 'status' => TwoFactorAuth::getAuthenticatorStatus($user), + 'settings' => TwoFactorAuth::getTwoFaSettingsForUser($user)->value, + ]); + } + + /** + * Replace the current user's recovery codes. Requires the current password. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function regenerateRecoveryCodes(Request $request) + { + $user = $request->user(); + if (!$user->checkPassword((string) $request->input('password'))) { + return response()->error('The current password provided is invalid.', 422); + } + + try { + $recoveryCodes = TwoFactorAuth::regenerateRecoveryCodes($user); + } catch (\Exception $e) { + return response()->error($e->getMessage(), 422); + } + + return response()->json([ + 'recovery_codes' => $recoveryCodes, + 'status' => TwoFactorAuth::getAuthenticatorStatus($user), + ]); + } + /** * Invite a user (new or existing) to join the current organisation. * @@ -1026,6 +1129,11 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) $user->activate(); } + // allow the user to set their first password without a current password + if ($needsPassword) { + Auth::markPasswordSetupPending($user); + } + // create authentication token for user $token = $user->createToken($invite->code); @@ -1365,10 +1473,15 @@ public function removeFromCompany($id) } /** - * Updates the current users password. + * Sets the current user's first password, e.g. right after accepting an invite. + * + * No IAM permission is required because the user cannot skip this step, but it is + * only allowed once, while the password setup allowance is pending. Afterwards the + * password must be changed with `changeUserPassword`. * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function setCurrentUserPassword(UpdatePasswordRequest $request) { $password = $request->input('password'); @@ -1379,7 +1492,14 @@ public function setCurrentUserPassword(UpdatePasswordRequest $request) return response()->error('User not authenticated'); } + if (!Auth::isPasswordSetupPending($user)) { + return response()->error('Your password has already been set. Use change password instead.', 403); + } + $user->changePassword($password); + Auth::clearPasswordSetupPending($user); + + activity('auth')->causedBy($user)->performedOn($user)->event('password_set')->log('Password set'); return response()->json(['status' => 'ok']); } @@ -1417,33 +1537,57 @@ public function export(ExportRequest $request) /** * Validate the user's current password. * + * Only checks the user's own password, so no IAM permission is required. + * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function validatePassword(ValidatePasswordRequest $request) { return response()->json(['status' => 'ok']); } /** - * Change the user's password. + * Change the current user's password. + * + * Requires the current password, and that the user may change their own password + * (see `Auth::canChangeOwnPassword`). The generic resource check is skipped because + * it would require the `create user` permission for this POST. * * @return \Illuminate\Http\Response */ - public function changeUserPassword(UpdatePasswordRequest $request) + #[SkipAuthorizationCheck] + public function changeUserPassword(ChangeCurrentPasswordRequest $request) { - $user = $request->user(); - $newPassword = $request->input('password'); - $newConfirmPassword = $request->input('password_confirmation'); + $user = $request->user(); - if ($newPassword !== $newConfirmPassword) { - return response()->error('Password is not matching'); + if (!Auth::canChangeOwnPassword($user)) { + return response()->error('You are not allowed to change your password. Ask an administrator.', 403); } - $user->changePassword($newPassword); + $user->changePassword($request->input('password')); + Auth::clearPasswordSetupPending($user); + + activity('auth')->causedBy($user)->performedOn($user)->event('password_changed')->log('Password changed'); return response()->json(['status' => 'ok']); } + /** + * Get the current user's password policy. + * + * @return \Illuminate\Http\Response + */ + #[SkipAuthorizationCheck] + public function getPasswordPolicy(Request $request) + { + $user = $request->user(); + + return response()->json([ + 'can_change_password' => Auth::canChangeOwnPassword($user), + ]); + } + /** * Save the user selected locale. * diff --git a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php index 46731192..869eb880 100644 --- a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php +++ b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php @@ -37,6 +37,11 @@ class WebhookEndpointController extends FleetbaseController */ public $service = 'developers'; + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'webhook'; + /** * Enables a webhook endpoint. * diff --git a/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php b/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php index f172c7a7..9d4253db 100644 --- a/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php +++ b/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php @@ -19,4 +19,9 @@ class WebhookRequestLogController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'log'; } diff --git a/src/Http/Filter/CompanyFilter.php b/src/Http/Filter/CompanyFilter.php index 47db8b1b..15dadfcb 100644 --- a/src/Http/Filter/CompanyFilter.php +++ b/src/Http/Filter/CompanyFilter.php @@ -29,7 +29,18 @@ function ($query) { public function query(?string $searchQuery) { - $this->builder->searchWhere('name', $searchQuery); + $this->builder->where(function ($query) use ($searchQuery) { + foreach (['name', 'description', 'phone', 'website_url', 'public_id', 'slug', 'country', 'timezone'] as $column) { + $query->orWhereRaw('LOWER(companies.' . $column . ') LIKE ?', ['%' . mb_strtolower(trim($searchQuery ?? '')) . '%']); + } + $query->orWhereHas('owner', function ($owner) use ($searchQuery) { + $owner->where(function ($query) use ($searchQuery) { + foreach (['name', 'email', 'phone', 'ip_address'] as $column) { + $query->orWhereRaw('LOWER(users.' . $column . ') LIKE ?', ['%' . mb_strtolower(trim($searchQuery ?? '')) . '%']); + } + }); + }); + }); } public function name(?string $name) @@ -39,12 +50,45 @@ public function name(?string $name) public function country(?string $country) { - $this->builder->searchWhere('country', $country); + $this->builder->where('country', strtoupper($country ?? '')); } public function status(?string $status) { - $this->builder->searchWhere('status', $status); + if ($status === 'active') { + $this->builder->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'active'); + }); + + return; + } + + $this->builder->where('status', $status); + } + + public function timezone(?string $timezone) + { + $this->builder->where('timezone', $timezone); + } + + public function type(?string $type) + { + $this->builder->where('type', $type); + } + + public function ipAddress(?string $ipAddress) + { + $this->builder->whereHas('owner', fn ($query) => $query->where('ip_address', $ipAddress)); + } + + public function ownerName(?string $name) + { + $this->builder->whereHas('owner', fn ($query) => $query->searchWhere('name', $name)); + } + + public function ownerPhone(?string $phone) + { + $this->builder->whereHas('owner', fn ($query) => $query->searchWhere('phone', $phone)); } public function ownerEmail(?string $email) @@ -121,4 +165,24 @@ public function createdAt(?string $date) $this->builder->whereDate('created_at', $date); } + + public function createdAtBetween(?string $from, ?string $to) + { + $this->dateRange('created_at', $from, $to); + } + + public function updatedAtBetween(?string $from, ?string $to) + { + $this->dateRange('updated_at', $from, $to); + } + + private function dateRange(string $column, ?string $from, ?string $to): void + { + if ($from) { + $this->builder->whereDate($column, '>=', $from); + } + if ($to) { + $this->builder->whereDate($column, '<=', $to); + } + } } diff --git a/src/Http/Requests/Internal/ChangeCurrentPasswordRequest.php b/src/Http/Requests/Internal/ChangeCurrentPasswordRequest.php new file mode 100644 index 00000000..1316ff50 --- /dev/null +++ b/src/Http/Requests/Internal/ChangeCurrentPasswordRequest.php @@ -0,0 +1,77 @@ + [ + 'required', + 'string', + function ($attribute, $value, $fail) { + $user = $this->user(); + if (!$user || !$user->checkPassword($value)) { + $fail('The current password provided is invalid.'); + } + }, + ], + 'password' => [ + 'required', + 'string', + 'confirmed', + 'different:current_password', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => ['required', 'string'], + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'current_password.required' => 'The current password is required.', + 'password.required' => 'A new password is required.', + 'password.confirmed' => 'Passwords do not match.', + 'password.different' => 'The new password must be different from the current password.', + 'password.min' => 'Password must be at least 8 characters.', + 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'password.letters' => 'Password must contain at least one letter.', + 'password.numbers' => 'Password must contain at least one number.', + 'password.symbols' => 'Password must contain at least one symbol.', + 'password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', + ]; + } +} diff --git a/src/Http/Resources/Organization.php b/src/Http/Resources/Organization.php index b5a095a9..1960a49c 100644 --- a/src/Http/Resources/Organization.php +++ b/src/Http/Resources/Organization.php @@ -37,8 +37,9 @@ public function toArray($request) 'name' => $this->name, 'description' => $this->description, 'phone' => $this->phone, + 'website_url' => $this->when(Http::isInternalRequest(), $this->website_url), 'type' => $this->when(Http::isInternalRequest(), $this->type), - 'users_count' => $this->when(Http::isInternalRequest(), $this->companyUsers()->count()), + 'users_count' => $this->when(Http::isInternalRequest(), fn () => $this->resource->users_count ?? $this->users()->count()), 'timezone' => $this->timezone, 'country' => $this->country, 'currency' => $this->currency, diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index f01b108d..0b70e4cd 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -60,6 +60,10 @@ public function toArray($request) 'created_at' => $this->created_at, ]; + if (Http::isInternalRequest() && $request->user()?->isAdmin()) { + $data = array_merge($data, $this->resource->getAttribute('admin_authentication') ?? []); + } + return ResourceTransformerRegistry::transform($this->resource, $data); } diff --git a/src/Models/Company.php b/src/Models/Company.php index 0e202cce..882bfcb6 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -186,7 +186,7 @@ public function users(): BelongsToMany 'user_uuid', 'uuid', 'uuid' - ); + )->wherePivotNull('deleted_at'); } public function companyUsers(): HasManyThrough diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 7c29cacb..9eda4995 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -279,7 +279,26 @@ public static function lookupFromCompany(string $key, $defaultValue = null) return $defaultValue; } - return static::lookup('company.' . session('company') . '.' . $key, $defaultValue); + return static::lookupForCompany(session('company'), $key, $defaultValue); + } + + /** + * Retrieves a setting for the given company. Use this where there is no company session, + * such as queued jobs and console commands. + * + * @param string|null $companyUuid the company the setting belongs to + * @param string $key the setting key associated with the company + * @param mixed|null $defaultValue the default value to return if the setting or company is not found + * + * @return mixed returns the value of the setting if found, or the default value if not + */ + public static function lookupForCompany(?string $companyUuid, string $key, $defaultValue = null) + { + if (!$companyUuid) { + return $defaultValue; + } + + return static::lookup('company.' . $companyUuid . '.' . $key, $defaultValue); } /** diff --git a/src/Models/UserDevice.php b/src/Models/UserDevice.php index 88d3b9d4..05b8d8c2 100644 --- a/src/Models/UserDevice.php +++ b/src/Models/UserDevice.php @@ -31,7 +31,16 @@ class UserDevice extends Model * * @var array */ - protected $fillable = ['user_uuid', 'platform', 'token', 'status']; + protected $fillable = ['user_uuid', 'platform', 'app_identifier', 'environment', 'token', 'status', 'last_seen_at']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'last_seen_at' => 'datetime', + ]; /** * Dynamic attributes that are appended to object. diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index b3ae6efb..c7b45b63 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -65,7 +65,7 @@ public static function boot() { parent::boot(); static::creating(function ($model) { - $model->code = mt_rand(100000, 999999); + $model->code = random_int(100000, 999999); }); } diff --git a/src/Support/Auth.php b/src/Support/Auth.php index f5a21a4f..036fa0c4 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -10,8 +10,10 @@ use Fleetbase\Models\Permission; use Fleetbase\Models\Policy; use Fleetbase\Models\Role; +use Fleetbase\Models\Setting; use Fleetbase\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth as Authentication; use Illuminate\Support\Facades\Hash; @@ -94,6 +96,70 @@ public static function canGrantRole(Role $role, ?User $actor): bool return $actor instanceof User && ($actor->isAdmin() || $actor->hasRole('Administrator')); } + /** + * Get the organization's authentication settings, with defaults applied. + */ + public static function getCompanyAuthSettings(?string $companyUuid): array + { + $settings = Setting::lookupForCompany($companyUuid, 'auth', []); + + return [ + 'allow_users_change_password' => Utils::castBoolean(data_get($settings, 'allow_users_change_password', true)), + ]; + } + + /** + * Whether the user may change their own password. Admins and users holding the + * Administrator role always can. Everyone else can when the organization allows + * users to change their own password, or when they hold the + * `iam change-password` permission. + */ + public static function canChangeOwnPassword(User $user): bool + { + if ($user->isAdmin() || $user->hasRole('Administrator')) { + return true; + } + + $settings = static::getCompanyAuthSettings(session('company', $user->company_uuid)); + if ($settings['allow_users_change_password']) { + return true; + } + + $permissions = Permission::findByNames(['iam change-password', 'iam *']); + + return $permissions->isNotEmpty() && $user->companyUser && $user->hasPermissions($permissions); + } + + /** + * Allow the user to set their first password without the current password, for + * example right after accepting an invite. The allowance expires after the given + * number of hours and is used up once the password is set. + */ + public static function markPasswordSetupPending(User $user, int $expiresInHours = 24): void + { + Setting::configure('user.' . $user->uuid . '.password_setup_pending', [ + 'expires_at' => now()->addHours($expiresInHours)->toIso8601String(), + ]); + } + + /** + * Whether the user may still set their first password. + */ + public static function isPasswordSetupPending(User $user): bool + { + $expiresAt = data_get(Setting::lookup('user.' . $user->uuid . '.password_setup_pending'), 'expires_at'); + + return $expiresAt && now()->lt(Carbon::parse($expiresAt)); + } + + /** + * Use up the user's allowance to set their first password. + */ + public static function clearPasswordSetupPending(User $user): void + { + Setting::where('key', 'user.' . $user->uuid . '.password_setup_pending')->delete(); + } + /** * Set session variables for user. * @@ -395,7 +461,7 @@ public static function resolvePermissionsFromRequest(Request $request): Collecti } $service = $controller->getService(); - $resource = str_replace('_', '-', $controller->getResourceSingularName()); + $resource = static::getPermissionResourceFromController($controller); $action = ActionMapper::resolve($request, $resource); // If the resource is not guarded at all @@ -522,12 +588,24 @@ public static function applyDirectivesToQuery($builder, ?Request $request = null public static function getRequiredPermissionNameFromRequest(Request $request): string { $controller = $request->getController(); - $resource = str_replace('_', '-', $controller->getResourceSingularName()); + $resource = static::getPermissionResourceFromController($controller); $action = ActionMapper::resolve($request, $resource); return implode(' ', [$action, $resource]); } + /** + * Resolves the permission resource name for a resource controller. + */ + public static function getPermissionResourceFromController($controller): string + { + if (method_exists($controller, 'getPermissionResourceName')) { + return $controller->getPermissionResourceName(); + } + + return str_replace('_', '-', $controller->getResourceSingularName()); + } + /** * Checks if a resource is guarded by any permissions. * @@ -569,6 +647,28 @@ public static function can(string $permission): bool }); } + /** + * Determines if the current user lacks the specified permission, treating platform + * administrators (and a missing session user) the way AuthorizationGuard does. + * + * For explicit checks in controllers that the guard cannot resolve on its own. + * + * @param string $permission the permission string in the format '{service} {action} {resource}' + */ + public static function cannotUnlessAdmin(string $permission): bool + { + $user = static::getUserFromSession(); + if (!$user) { + return true; + } + + if ($user->isAdmin()) { + return false; + } + + return static::cannot($permission); + } + /** * Determines if the current user lacks the specified permission. * diff --git a/src/Support/Barcode.php b/src/Support/Barcode.php new file mode 100644 index 00000000..5841bc60 --- /dev/null +++ b/src/Support/Barcode.php @@ -0,0 +1,107 @@ +' + . '' + . '' + . ''; + } + + /** + * Render a QR code as an SVG data URI, ready for an ``. + * + * @param string $data the content to encode + * @param string $errorCorrection L, M, Q or H + */ + public static function qrCodeDataUri(string $data, string $errorCorrection = 'M'): string + { + return 'data:image/svg+xml;base64,' . base64_encode(static::qrCodeSvg($data, $errorCorrection)); + } + + /** + * Render a 2D barcode as a base64-encoded PNG, with a transparent background. + * + * @param string $data the content to encode + * @param string $type a `milon/barcode` 2D type, e.g. `QRCODE`, `QRCODE,H`, `PDF417` or `DATAMATRIX` + * @param int $w the width of one module, in pixels + * @param int $h the height of one module, in pixels + * + * @return string|false the PNG, or false when no image library is available + */ + public static function png(string $data, string $type = 'QRCODE', int $w = 3, int $h = 3): string|false + { + return (new DNS2D())->setStorPath(sys_get_temp_dir())->getBarcodePNG($data, $type, $w, $h); + } + + /** + * Get the module matrix for a QR code. + * + * @return array with `num_rows`, `num_cols` and the `bcode` module rows + */ + protected static function qrCodeMatrix(string $data, string $errorCorrection): array + { + $errorCorrection = strtoupper($errorCorrection); + if (!in_array($errorCorrection, ['L', 'M', 'Q', 'H'], true)) { + throw new \InvalidArgumentException('QR code error correction must be L, M, Q or H.'); + } + + $matrix = $data === '' ? false : (new QRcode($data, $errorCorrection))->getBarcodeArray(); + if (!is_array($matrix) || empty($matrix['num_rows'])) { + throw new \InvalidArgumentException('The data could not be encoded as a QR code.'); + } + + return $matrix; + } +} diff --git a/src/Support/NotificationRegistry.php b/src/Support/NotificationRegistry.php index ac55ae02..2714dfe0 100644 --- a/src/Support/NotificationRegistry.php +++ b/src/Support/NotificationRegistry.php @@ -2,6 +2,7 @@ namespace Fleetbase\Support; +use Fleetbase\Models\Company; use Fleetbase\Models\Setting; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Schema; @@ -28,7 +29,7 @@ class NotificationRegistry \Fleetbase\Models\User::class, \Fleetbase\Models\Group::class, \Fleetbase\Models\Role::class, - \Fleetbase\Models\Company::class, + Company::class, ]; /** @@ -251,7 +252,7 @@ public static function notify($notificationClass, ...$params): void } // resolve settings for notification - $notificationSettings = Setting::lookupCompany('notification_settings'); + $notificationSettings = static::resolveNotificationSettings(...$params); // Get the notification class definition $definition = static::findNotificationRegistrationByDefinition($notificationClass); @@ -324,7 +325,7 @@ public static function notifyUsingDefinitionName($notificationClass, $notificati } // resolve settings for notification - $notificationSettings = Setting::lookup('notification_settings'); + $notificationSettings = static::resolveNotificationSettings(...$params); // iterate the properties to find the notifications key starting with the class $notificationSettingsKey = Str::camel(str_replace('\\', '', $notificationClass)) . '__' . Str::camel($notificationName); @@ -362,6 +363,34 @@ public static function notifyUsingDefinitionName($notificationClass, $notificati } } + /** + * Resolve the notification settings for the company the notification is about. + * + * The company is taken from the notification parameters (a company, or a model + * with a `company_uuid`) because notifications are often sent from queued jobs + * and console commands, where there is no company session. + * + * @param mixed ...$params the parameters passed to the notification class + */ + protected static function resolveNotificationSettings(...$params): mixed + { + $companyUuid = null; + + foreach ($params as $param) { + if ($param instanceof Company) { + $companyUuid = $param->uuid; + break; + } + + if ($param instanceof Model && $param->getAttribute('company_uuid')) { + $companyUuid = $param->getAttribute('company_uuid'); + break; + } + } + + return Setting::lookupForCompany($companyUuid ?? session('company'), 'notification_settings', []); + } + /** * Resolve a notifiable object to an Eloquent model. * diff --git a/src/Support/OrganizationAdminSummary.php b/src/Support/OrganizationAdminSummary.php new file mode 100644 index 00000000..a0d16c70 --- /dev/null +++ b/src/Support/OrganizationAdminSummary.php @@ -0,0 +1,71 @@ +getConnection(); + $schema = $connection->getSchemaBuilder(); + $usage = ['users_count' => $company->users()->count()]; + + foreach ([ + 'drivers_count' => 'drivers', + 'customers_count' => 'contacts', + 'orders_count' => 'orders', + 'api_requests_count' => 'api_request_logs', + 'webhook_callbacks_count' => 'webhook_request_logs', + ] as $key => $table) { + // Fleet-Ops is optional. An unavailable module is distinct from zero records. + if (!$schema->hasTable($table)) { + $usage[$key] = null; + continue; + } + + $query = $connection->table($table)->where('company_uuid', $company->uuid); + if ($schema->hasColumn($table, 'deleted_at')) { + $query->whereNull('deleted_at'); + } + if ($table === 'contacts') { + $query->where('type', 'customer'); + } + $usage[$key] = $query->count(); + } + + return $usage; + } + + /** + * Attach only non-secret authentication facts, using two queries for the entire page. + * Reading the admin view must not create default 2FA settings for other users. + */ + public static function attachAuthentication(Collection $users): void + { + if ($users->isEmpty()) { + return; + } + + $keys = $users->map(fn ($user) => 'user.' . $user->uuid . '.2fa')->all(); + $settings = Setting::whereIn('key', $keys)->get(['key', 'value'])->keyBy('key'); + $identities = OAuthIdentity::whereIn('user_uuid', $users->pluck('uuid'))->get(['user_uuid', 'provider'])->groupBy('user_uuid'); + + foreach ($users as $user) { + $settingsForUser = $settings->get('user.' . $user->uuid . '.2fa'); + $enabled = filter_var(data_get($settingsForUser, 'value.enabled', false), FILTER_VALIDATE_BOOLEAN); + $user->setAttribute('admin_authentication', [ + 'two_factor_enabled' => $enabled, + 'two_factor_method' => $enabled ? data_get($settingsForUser, 'value.method', 'email') : null, + 'oauth_providers' => $identities->get($user->uuid, collect())->pluck('provider')->unique()->sort()->values()->all(), + ]); + } + } +} diff --git a/src/Support/PushNotification.php b/src/Support/PushNotification.php index a48516df..21bac77a 100644 --- a/src/Support/PushNotification.php +++ b/src/Support/PushNotification.php @@ -52,6 +52,8 @@ public static function createFcmMessage(string $title, string $body, array $data ->data($data) ->custom([ 'android' => [ + // High priority so the push is delivered while the device is in Doze + 'priority' => 'high', 'notification' => [ 'color' => '#4391EA', 'sound' => 'default', diff --git a/src/Support/TwoFactorAuth.php b/src/Support/TwoFactorAuth.php index 7eed6c74..02e8efe0 100644 --- a/src/Support/TwoFactorAuth.php +++ b/src/Support/TwoFactorAuth.php @@ -9,14 +9,51 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; +use PragmaRX\Google2FA\Google2FA; /** * Class TwoFactorAuth. */ class TwoFactorAuth { + /** + * Lifetime of a 2FA session in seconds. + */ + public const SESSION_TTL = 600; + + /** + * Failed code attempts allowed per 2FA session before it is invalidated. + */ + public const MAX_VERIFY_ATTEMPTS = 5; + + /** + * The 2FA method that uses codes from an authenticator app (TOTP, RFC 6238). + */ + public const METHOD_AUTHENTICATOR_APP = 'authenticator_app'; + + /** + * Seconds a user has to confirm a new authenticator app with its first code. + */ + public const AUTHENTICATOR_ENROLLMENT_TTL = 900; + + /** + * Number of one-time recovery codes issued with an authenticator app. + */ + public const RECOVERY_CODE_COUNT = 8; + + /** + * Authenticator codes accepted either side of the current 30 second step, to allow for clock drift. + */ + public const AUTHENTICATOR_WINDOW = 1; + + /** + * Marks a client token for an authenticator app challenge, instead of a sent code. + */ + private const AUTHENTICATOR_CLIENT_TOKEN = 'authenticator'; + /** * Save Two-Factor Authentication settings for System wide usage. * @@ -163,6 +200,17 @@ public static function getClientSessionTokenFromTwoFaSession(string $token, stri throw new \Exception('2FA Authentication is not enabled.'); } + // An authenticator app challenge stays valid for as long as the 2FA session does + if ($clientToken && static::isAuthenticatorClientToken($clientToken)) { + if (static::getUserFromAuthenticatorClientToken($clientToken)?->uuid === $user->uuid + && static::isTwoFaSessionKeyValid(static::decryptSessionKey($token, $user->uuid), $user)) { + return $clientToken; + } + + static::forgetTwoFaSession($token, $identity); + throw new \Exception('2FA Verification session has expired.'); + } + // If a client session token is provided validate by fetching the verification code // If a verification code exists then we just return the current valid client session if ($clientToken) { @@ -190,6 +238,11 @@ public static function getClientSessionTokenFromTwoFaSession(string $token, stri // Validate session key that it is valid and exists if (static::isTwoFaSessionKeyValid($twoFaSessionKey, $user)) { + // Authenticator app users type the code from their app, so nothing is sent + if (static::usesAuthenticatorApp($user)) { + return static::createAuthenticatorClientToken($user); + } + // Send the verification code then create a client session for the verification code and user $verificationCode = static::sendVerificationCode($user); $clientToken = static::createClientSessionToken($verificationCode); @@ -200,6 +253,26 @@ public static function getClientSessionTokenFromTwoFaSession(string $token, stri throw new \Exception('2FA Authentication session is invalid'); } + /** + * Get how the user answers a 2FA challenge: `authenticator_app` when the client token + * is for an authenticator app, otherwise the method the code was sent with. + * + * @param string $identity the user identity + * @param string $clientToken the client session token + */ + public static function getChallengeMethod(string $identity, string $clientToken): string + { + if (static::isAuthenticatorClientToken($clientToken)) { + return static::METHOD_AUTHENTICATOR_APP; + } + + $user = static::getUserFromIdentity($identity); + $method = $user ? (string) static::getTwoFaSettingsForUser($user)->getValue('method', 'email') : 'email'; + + // Authenticator app users only get a sent code as a fallback, see sendVerificationCode() + return $method === static::METHOD_AUTHENTICATOR_APP && $user ? static::fallbackMethod($user) : $method; + } + /** * Validate a Two-Factor Authentication session token. * @@ -222,6 +295,11 @@ public static function validateSessionToken(string $token, string $identity, ?st return false; } + if ($clientToken && static::isAuthenticatorClientToken($clientToken)) { + return static::getUserFromAuthenticatorClientToken($clientToken)?->uuid === $user->uuid + && static::isTwoFaSessionKeyValid(static::decryptSessionKey($token, $user->uuid), $user); + } + // If a client session token is provided validate by fetching the verification code // If a verification code exists then we just return the current valid client session if ($clientToken) { @@ -273,6 +351,12 @@ public static function sendVerificationCode(User $user, int $expiresAfter = 61): $method = $twoFaSettings->getValue('method', 'email'); $expiresAfter = Carbon::now()->addSeconds($expiresAfter); + // A code can still be sent to authenticator app users, as a fallback when they + // cannot use their app. + if ($method === static::METHOD_AUTHENTICATOR_APP) { + $method = static::fallbackMethod($user); + } + // Create SMS and Email message callback $messageCallback = function ($verificationCode) { return $verificationCode->code . ' is your ' . config('app.name') . ' 2FA Code'; @@ -313,6 +397,10 @@ public static function sendVerificationCode(User $user, int $expiresAfter = 61): /** * Create a Two-Factor Authentication session if enabled. * + * @deprecated a 2FA session must only be started once the user's first factor (password or + * OAuth provider) has been verified, otherwise the code alone is enough to sign in. + * Use start() with the authenticated user instead. + * * @param string $identity the user identity * * @return string|null the Two-Factor Authentication session key, or null if not enabled @@ -433,6 +521,10 @@ public static function start(string|User $identity, int $tokenLength = 40): ?str */ public static function verifyCode(string $code, string $token, string $clientToken): string { + if (static::isAuthenticatorClientToken($clientToken)) { + return static::verifyAuthenticatorChallenge($code, $token, $clientToken); + } + // Get verification code from the client token $verificationCode = static::getVerificationCodeFromClientToken($clientToken); @@ -470,10 +562,11 @@ public static function verifyCode(string $code, string $token, string $clientTok } // Check if verification code matches user provided code - $verificationCodeMatches = $verificationCode->code === $code; + $verificationCodeMatches = hash_equals((string) $verificationCode->code, $code); if ($verificationCodeMatches) { // Kill the two fa session - Redis::del($twoFaSessionKey); + Redis::del($twoFaSessionKey, static::attemptsKey($twoFaSessionKey)); + static::logActivity($user, 'two_factor_verified', 'Two-factor sign-in verified', ['method' => 'code']); // Authenticate the user $token = $user->createToken($user->uuid); @@ -481,6 +574,8 @@ public static function verifyCode(string $code, string $token, string $clientTok return $token->plainTextToken; } + static::recordFailedAttempt($twoFaSessionKey, $user); + throw new \Exception('Verification code does not match.'); } } @@ -584,6 +679,358 @@ public static function forgetTwoFaSession(string $token, string $identity): bool return Redis::del($twoFaSessionKey); } + /** + * Whether the user signs in with an authenticator app they have confirmed. + */ + public static function usesAuthenticatorApp(User $user): bool + { + return static::hasAuthenticatorApp($user) + && static::getTwoFaSettingsForUser($user)->getValue('method') === static::METHOD_AUTHENTICATOR_APP; + } + + /** + * Whether the user has confirmed an authenticator app. + */ + public static function hasAuthenticatorApp(User $user): bool + { + return !empty(static::getAuthenticatorRecord($user)['confirmed_at']); + } + + /** + * Describe the user's authenticator app, without any secrets. + * + * @return array{enabled: bool, confirmed_at: string|null, recovery_codes_remaining: int} + */ + public static function getAuthenticatorStatus(User $user): array + { + $record = static::getAuthenticatorRecord($user); + + return [ + 'enabled' => !empty($record['confirmed_at']), + 'confirmed_at' => $record['confirmed_at'] ?? null, + 'recovery_codes_remaining' => count($record['recovery_codes'] ?? []), + ]; + } + + /** + * Start setting up an authenticator app. The new secret is kept aside until the user + * confirms it with a code, so an app that is already set up keeps working until then. + * + * @return array{secret: string, otpauth_url: string, qr_code: string, expires_at: string} + */ + public static function beginAuthenticatorEnrollment(User $user): array + { + $google2fa = static::google2fa(); + $secret = $google2fa->generateSecretKey(32); + $expiresAt = Carbon::now()->addSeconds(static::AUTHENTICATOR_ENROLLMENT_TTL); + $issuer = (string) config('app.name', 'Fleetbase'); + $account = $user->email ?: ($user->phone ?: ($user->username ?: $user->uuid)); + $url = $google2fa->getQRCodeUrl($issuer, $account, $secret); + + static::saveAuthenticatorRecord($user, array_merge(static::getAuthenticatorRecord($user), [ + 'pending_secret' => Crypt::encryptString($secret), + 'pending_expires_at' => $expiresAt->toIso8601String(), + ])); + + return [ + 'secret' => $secret, + 'otpauth_url' => $url, + 'qr_code' => Barcode::qrCodeDataUri($url), + 'expires_at' => $expiresAt->toIso8601String(), + ]; + } + + /** + * Confirm a new authenticator app with a code from it, make it the user's 2FA method, + * and issue new recovery codes. + * + * @return array the recovery codes, which are only ever shown here + * + * @throws \Exception if there is no pending setup, it expired, or the code is wrong + */ + public static function confirmAuthenticatorEnrollment(User $user, string $code): array + { + $record = static::getAuthenticatorRecord($user); + $expiresAt = $record['pending_expires_at'] ?? null; + + if (empty($record['pending_secret']) || !$expiresAt || Carbon::now()->gte(Carbon::parse($expiresAt))) { + throw new \Exception('Authenticator setup has expired. Start again.'); + } + + // verifyKeyNewer() returns the matched time step only when given a previous one + $secret = Crypt::decryptString($record['pending_secret']); + $timestep = static::google2fa()->verifyKeyNewer($secret, static::normalizeOtp($code), 0, static::AUTHENTICATOR_WINDOW); + if ($timestep === false) { + throw new \Exception('The code from your authenticator app is not correct.'); + } + + [$recoveryCodes, $hashes] = static::generateRecoveryCodes(); + + static::saveAuthenticatorRecord($user, [ + 'secret' => Crypt::encryptString($secret), + 'confirmed_at' => Carbon::now()->toIso8601String(), + 'last_timestep' => $timestep, + 'recovery_codes' => $hashes, + ]); + static::saveTwoFaSettingsForUser($user, array_merge(static::getTwoFaSettingsForUser($user)->value ?? [], [ + 'enabled' => true, + 'method' => static::METHOD_AUTHENTICATOR_APP, + ])); + static::logActivity($user, 'authenticator_enabled', 'Authenticator app enabled'); + + return $recoveryCodes; + } + + /** + * Remove the user's authenticator app. If it was their 2FA method, 2FA is turned off + * so they are not locked out; they can turn it on again with another method. + */ + public static function disableAuthenticatorApp(User $user): void + { + Setting::where('key', static::authenticatorKey($user))->delete(); + + $settings = static::getTwoFaSettingsForUser($user)->value ?? []; + if (($settings['method'] ?? null) === static::METHOD_AUTHENTICATOR_APP) { + static::saveTwoFaSettingsForUser($user, array_merge($settings, ['enabled' => false, 'method' => 'email'])); + } + + static::logActivity($user, 'authenticator_disabled', 'Authenticator app disabled'); + } + + /** + * Replace the user's recovery codes. + * + * @return array the new recovery codes, which are only ever shown here + * + * @throws \Exception if the user has no authenticator app + */ + public static function regenerateRecoveryCodes(User $user): array + { + $record = static::getAuthenticatorRecord($user); + if (empty($record['confirmed_at'])) { + throw new \Exception('Set up an authenticator app first.'); + } + + [$recoveryCodes, $hashes] = static::generateRecoveryCodes(); + $record['recovery_codes'] = $hashes; + static::saveAuthenticatorRecord($user, $record); + static::logActivity($user, 'recovery_codes_regenerated', 'Two-factor recovery codes regenerated'); + + return $recoveryCodes; + } + + /** + * Check a code from the user's authenticator app, or one of their recovery codes. + * Each authenticator code and each recovery code can only be used once. + * + * @return string|null `authenticator_app` or `recovery_code` for the kind of code that matched, or null + */ + public static function verifyAuthenticatorCode(User $user, string $code): ?string + { + $record = static::getAuthenticatorRecord($user); + if (empty($record['secret'])) { + return null; + } + + $otp = static::normalizeOtp($code); + if (preg_match('/^\d{6}$/', $otp)) { + $timestep = static::google2fa()->verifyKeyNewer( + Crypt::decryptString($record['secret']), + $otp, + (int) ($record['last_timestep'] ?? 0), + static::AUTHENTICATOR_WINDOW + ); + + if ($timestep === false) { + return null; + } + + $record['last_timestep'] = $timestep; + static::saveAuthenticatorRecord($user, $record); + + return static::METHOD_AUTHENTICATOR_APP; + } + + $hash = static::hashRecoveryCode($code); + $codes = $record['recovery_codes'] ?? []; + foreach ($codes as $index => $storedHash) { + if (hash_equals((string) $storedHash, $hash)) { + unset($codes[$index]); + $record['recovery_codes'] = array_values($codes); + static::saveAuthenticatorRecord($user, $record); + static::logActivity($user, 'recovery_code_used', 'Two-factor recovery code used', ['remaining' => count($codes)]); + + return 'recovery_code'; + } + } + + return null; + } + + /** + * Verify an authenticator app challenge and return a user token. + * + * @throws \Exception if the challenge is invalid or the code is wrong + */ + private static function verifyAuthenticatorChallenge(string $code, string $token, string $clientToken): string + { + $user = static::getUserFromAuthenticatorClientToken($clientToken); + if (!$user) { + throw new \Exception('Verification code is invalid.'); + } + + $twoFaSessionKey = static::decryptSessionKey($token, $user->uuid); + if (!static::isTwoFaSessionKeyValid($twoFaSessionKey, $user)) { + throw new \Exception('Verification code is invalid.'); + } + + $matched = static::verifyAuthenticatorCode($user, $code); + if (!$matched) { + static::recordFailedAttempt($twoFaSessionKey, $user); + + throw new \Exception('Verification code does not match.'); + } + + Redis::del($twoFaSessionKey, static::attemptsKey($twoFaSessionKey), static::authenticatorClientKey($clientToken)); + static::logActivity($user, 'two_factor_verified', 'Two-factor sign-in verified', ['method' => $matched]); + + return $user->createToken($user->uuid)->plainTextToken; + } + + /** + * Count a failed code against the 2FA session, which outlives any single code, so + * resending codes does not reset the budget. Ends the session after too many failures. + * + * @throws \Exception when the session has been ended + */ + private static function recordFailedAttempt(string $twoFaSessionKey, User $user): void + { + $attemptsKey = static::attemptsKey($twoFaSessionKey); + $attempts = (int) Redis::incr($attemptsKey); + Redis::expire($attemptsKey, static::SESSION_TTL); + + if ($attempts >= static::MAX_VERIFY_ATTEMPTS) { + Redis::del($twoFaSessionKey, $attemptsKey); + static::logActivity($user, 'two_factor_locked', 'Two-factor sign-in stopped after too many wrong codes', ['attempts' => $attempts]); + + throw new \Exception('Too many failed verification attempts. Please sign in again.'); + } + } + + /** + * The method used to send a code to an authenticator app user who cannot use their app. + */ + private static function fallbackMethod(User $user): string + { + return $user->email ? 'email' : 'sms'; + } + + /** + * Create a client token for an authenticator app challenge. It points at the user + * server side, so the token itself does not reveal who it is for. + */ + private static function createAuthenticatorClientToken(User $user): string + { + $reference = Str::random(40); + $clientToken = base64_encode(Carbon::now()->addSeconds(static::SESSION_TTL) . '|' . static::AUTHENTICATOR_CLIENT_TOKEN . '|' . $reference); + + Redis::set(static::authenticatorClientKey($clientToken), $user->uuid, 'EX', static::SESSION_TTL); + + return $clientToken; + } + + private static function isAuthenticatorClientToken(string $clientToken): bool + { + return (static::decodeClientToken($clientToken)[1] ?? null) === static::AUTHENTICATOR_CLIENT_TOKEN; + } + + private static function getUserFromAuthenticatorClientToken(string $clientToken): ?User + { + $userUuid = Redis::get(static::authenticatorClientKey($clientToken)); + + return $userUuid ? User::where('uuid', $userUuid)->first() : null; + } + + private static function authenticatorClientKey(string $clientToken): string + { + return 'two_fa_client:' . (static::decodeClientToken($clientToken)[2] ?? ''); + } + + /** + * Get the user's stored authenticator app record: the encrypted secret, when it was + * confirmed, the last used time step, and hashed recovery codes. + */ + private static function getAuthenticatorRecord(User $user): array + { + $record = Setting::lookup(static::authenticatorKey($user), []); + + return is_array($record) ? $record : []; + } + + private static function saveAuthenticatorRecord(User $user, array $record): void + { + Setting::configure(static::authenticatorKey($user), $record); + } + + private static function authenticatorKey(User $user): string + { + return 'user.' . $user->uuid . '.2fa_authenticator'; + } + + /** + * Generate recovery codes like `k7d2m-9xq4p`, and the hashes that are stored. + * + * @return array{0: array, 1: array} + */ + private static function generateRecoveryCodes(): array + { + $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + $codes = []; + + for ($i = 0; $i < static::RECOVERY_CODE_COUNT; $i++) { + $code = ''; + for ($j = 0; $j < 10; $j++) { + $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + $codes[] = substr($code, 0, 5) . '-' . substr($code, 5); + } + + return [$codes, array_map([static::class, 'hashRecoveryCode'], $codes)]; + } + + /** + * Recovery codes are random and single use, so a keyed SHA-256 hash is enough to store them. + */ + private static function hashRecoveryCode(string $code): string + { + $normalized = strtolower((string) preg_replace('/[^a-z0-9]/i', '', $code)); + + return hash_hmac('sha256', $normalized, (string) config('app.key', '')); + } + + private static function normalizeOtp(string $code): string + { + return (string) preg_replace('/\s+/', '', $code); + } + + private static function google2fa(): Google2FA + { + return new Google2FA(); + } + + /** + * Record a 2FA event in the `auth` activity log. Codes and secrets are never logged. + */ + private static function logActivity(User $user, string $event, string $description, array $properties = []): void + { + activity('auth') + ->causedBy($user) + ->performedOn($user) + ->withProperties($properties) + ->event($event) + ->log($description); + } + /** * Create a Two-Factor Authentication session key. * @@ -594,17 +1041,26 @@ public static function forgetTwoFaSession(string $token, string $identity): bool * * @return string the Two-Factor Authentication session key */ - private static function createTwoFaSessionKey(User $user, string $token, bool $storeInCache = true, int $expiresAfter = 600): string + private static function createTwoFaSessionKey(User $user, string $token, bool $storeInCache = true, int $expiresAfter = self::SESSION_TTL): string { $twoFaSessionKey = 'two_fa_session:' . $user->uuid . ':' . $token; if ($storeInCache) { - Redis::set($twoFaSessionKey, $user->uuid, 'EX', now()->addSeconds($expiresAfter)->timestamp); + // EX takes a TTL in seconds, not an absolute timestamp. + Redis::set($twoFaSessionKey, $user->uuid, 'EX', $expiresAfter); } return $twoFaSessionKey; } + /** + * Redis key holding the failed verification attempts for a 2FA session. + */ + private static function attemptsKey(string $twoFaSessionKey): string + { + return $twoFaSessionKey . ':attempts'; + } + /** * Get a user based on the provided identity (email or phone). * diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 589f35aa..d3395472 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -271,6 +271,22 @@ public function getResourceSingularName(): string return $this->resourceSingularlName; } + /** + * Gets the resource name used in permission names, e.g. "user" in "iam list user". + * + * Defaults to the kebab-cased singular resource name. A controller whose model name + * differs from its IAM schema resource declares `public string $permissionResource` + * (for example ApiCredentialController uses the schema resource "api-key"). + */ + public function getPermissionResourceName(): string + { + if (property_exists($this, 'permissionResource') && !empty($this->permissionResource)) { + return $this->permissionResource; + } + + return str_replace('_', '-', $this->getResourceSingularName()); + } + /** * Gets the service associated with the controller. * diff --git a/src/routes.php b/src/routes.php index 8aa671d7..e60690fc 100644 --- a/src/routes.php +++ b/src/routes.php @@ -281,10 +281,13 @@ function ($router, $controller) { $router->fleetbaseRoutes('companies', null, [], function ($router, $controller) { $router->get('two-fa', $controller('getTwoFactorSettings')); $router->post('two-fa', $controller('saveTwoFactorSettings')); + $router->get('auth-settings', $controller('getAuthSettings')); + $router->post('auth-settings', $controller('saveAuthSettings')); $router->post('transfer-ownership', $controller('transferOwnership')); $router->post('leave', $controller('leaveOrganization')); $router->match(['get', 'post'], 'export', $controller('export')); $router->get('{id}/extensions', $controller('extensions')); + $router->get('{id}/usage', $controller('usage')); $router->patch('{id}/status', $controller('setAdminStatus')); $router->patch('{id}/onboarding', $controller('setAdminOnboarding')); $router->post('{id}/transfer-ownership', $controller('transferOwnershipAdmin')); @@ -307,10 +310,16 @@ function ($router, $controller) { $router->post('invite-user', $controller('inviteUser')); $router->post('resend-invite', $controller('resendInvitation')); $router->post('set-password', $controller('setCurrentUserPassword')); - $router->post('validate-password', $controller('validatePassword')); - $router->post('change-password', $controller('changeUserPassword')); + $router->post('validate-password', $controller('validatePassword'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); + $router->post('change-password', $controller('changeUserPassword'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); + $router->get('password-policy', $controller('getPasswordPolicy')); $router->post('two-fa', $controller('saveTwoFactorSettings')); $router->get('two-fa', $controller('getTwoFactorSettings')); + $router->get('two-fa/authenticator', $controller('getAuthenticatorApp')); + $router->post('two-fa/authenticator/setup', $controller('setupAuthenticatorApp'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); + $router->post('two-fa/authenticator/confirm', $controller('confirmAuthenticatorApp'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); + $router->post('two-fa/authenticator/disable', $controller('disableAuthenticatorApp'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); + $router->post('two-fa/recovery-codes', $controller('regenerateRecoveryCodes'))->middleware(Illuminate\Routing\Middleware\ThrottleRequests::class . ':10,1'); $router->post('locale', $controller('setUserLocale')); $router->get('locale', $controller('getUserLocale')); } diff --git a/tests/Pest.php b/tests/Pest.php index 77acb87e..cae14328 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -403,6 +403,124 @@ public function runningUnitTests(): bool } } + /** + * A reversible stand-in for the host app's encrypter (core-api does not depend on + * illuminate/encryption). Encrypted values are prefixed so tests can tell them apart. + */ + class TestEncrypter implements Illuminate\Contracts\Encryption\Encrypter, Illuminate\Contracts\Encryption\StringEncrypter + { + public function encrypt(#[SensitiveParameter] $value, $serialize = true) + { + return 'encrypted:' . base64_encode($serialize ? serialize($value) : $value); + } + + public function decrypt($payload, $unserialize = true) + { + if (!str_starts_with((string) $payload, 'encrypted:')) { + throw new Illuminate\Contracts\Encryption\DecryptException('The payload is invalid.'); + } + + $value = base64_decode(substr($payload, 10)); + + return $unserialize ? unserialize($value) : $value; + } + + public function encryptString(#[SensitiveParameter] $value) + { + return $this->encrypt($value, false); + } + + public function decryptString($payload) + { + return $this->decrypt($payload, false); + } + + public function getKey() + { + return 'test-key'; + } + + public function getAllKeys() + { + return ['test-key']; + } + + public function getPreviousKeys() + { + return []; + } + } + + /** + * Records `activity()` calls made in tests. Read TestActivityLogger::$logged. + */ + class TestActivityLogger extends Spatie\Activitylog\ActivityLogger + { + public static array $logged = []; + + private array $entry = []; + + public function __construct(private ?string $logName = null) + { + } + + public function causedBy(Illuminate\Database\Eloquent\Model|int|string|null $modelOrId): static + { + $this->entry['causer'] = $modelOrId instanceof Illuminate\Database\Eloquent\Model ? $modelOrId->getKey() : $modelOrId; + + return $this; + } + + public function performedOn(Illuminate\Database\Eloquent\Model $model): static + { + $this->entry['subject'] = $model->getKey(); + + return $this; + } + + public function event(string $event): static + { + $this->entry['event'] = $event; + + return $this; + } + + public function withProperties(mixed $properties): static + { + $this->entry['properties'] = $properties; + + return $this; + } + + public function log(string $description): ?Spatie\Activitylog\Contracts\Activity + { + static::$logged[] = array_merge(['log' => $this->logName, 'description' => $description], $this->entry); + + return null; + } + } + + class TestPendingActivityLog extends Spatie\Activitylog\PendingActivityLog + { + private ?string $logName = null; + + public function __construct() + { + } + + public function useLog(?string $logName): static + { + $this->logName = $logName; + + return $this; + } + + public function logger(): Spatie\Activitylog\ActivityLogger + { + return new TestActivityLogger($this->logName); + } + } + function bind_test_container(array $config = []): Container { if (!Container::getInstance() instanceof FleetbaseTestContainer) { @@ -422,6 +540,14 @@ function bind_test_container(array $config = []): Container $container->instance('request', Request::create('/int/v1/test', 'GET')); + // activity() works in every test; files that assert on it bind their own fake + TestActivityLogger::$logged = []; + $container->instance(Spatie\Activitylog\PendingActivityLog::class, new TestPendingActivityLog()); + + if (!$container->bound('encrypter')) { + $container->instance('encrypter', new TestEncrypter()); + } + $container->instance('log', new class { public array $entries = []; diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 1008904d..50dcd298 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -6,6 +6,7 @@ use Fleetbase\Http\Requests\JoinOrganizationRequest; use Fleetbase\Http\Requests\LoginRequest; use Fleetbase\Models\User; +use Fleetbase\Support\TwoFactorAuth; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Events\Dispatcher; @@ -717,9 +718,35 @@ function auth_controller_insert_join_invite(Capsule $capsule, string $companyUui ->and($redis->sets)->toHaveCount(1) ->and($redis->sets[0]['key'])->toStartWith('two_fa_session:11111111-1111-4111-8111-111111111111:') ->and($redis->sets[0]['value'])->toBe('11111111-1111-4111-8111-111111111111') + // A relative TTL in seconds, not an absolute timestamp. + ->and($redis->sets[0]['options'])->toBe(['EX', TwoFactorAuth::SESSION_TTL]) ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); }); +test('login does not start two factor authentication or reveal it when the password is wrong', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => 'dispatcher', + 'email_verified_at' => '2026-07-18 10:00:00', + ]); + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'user.11111111-1111-4111-8111-111111111111.2fa', + 'value' => json_encode(['enabled' => true, 'method' => 'email']), + ]); + + $response = (new AuthController())->login(auth_controller_login_request([ + 'identity' => 'auth@example.test', + 'password' => 'not-the-password', + ])); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(401) + ->and($payload['code'])->toBe('invalid_credentials') + ->and($payload)->not->toHaveKey('twoFaSession') + ->and($payload)->not->toHaveKey('isEnabled') + ->and(app('redis')->sets)->toBe([]); +}); + test('bootstrap returns cached session and organization response contracts', function () { $capsule = auth_controller_login_bootstrap_database(); auth_controller_login_insert_user($capsule, [ diff --git a/tests/Unit/Http/CompanyControllerTest.php b/tests/Unit/Http/CompanyControllerTest.php index 0ecf7ef1..74b65700 100644 --- a/tests/Unit/Http/CompanyControllerTest.php +++ b/tests/Unit/Http/CompanyControllerTest.php @@ -461,6 +461,7 @@ function company_controller_fixtures(): Capsule $table->string('timezone')->nullable(); $table->string('country')->nullable(); $table->string('currency')->nullable(); + $table->string('website_url')->nullable(); $table->timestamp('onboarding_completed_at')->nullable(); $table->string('onboarding_completed_by_uuid')->nullable(); $table->timestamp('deleted_at')->nullable(); @@ -495,6 +496,13 @@ function company_controller_fixtures(): Capsule $table->string('key')->nullable()->index(); $table->text('value')->nullable(); }); + $schema->create('oauth_identities', function ($table) { + $table->string('uuid')->primary(); + $table->string('user_uuid')->index(); + $table->string('provider'); + $table->string('provider_user_id')->nullable(); + $table->text('meta')->nullable(); + }); $schema->create('invites', function ($table) { $table->string('uuid')->primary(); $table->string('public_id')->nullable(); @@ -762,17 +770,22 @@ function company_controller_bind_activity(): CompanyControllerActivityFake expect($foreign->getStatusCode())->toBe(404) ->and($foreign->getData(true))->toBe(['errors' => ['Organization not found.']]); + $before = $capsule->getConnection('mysql')->table('companies')->where('uuid', 'company-1')->first(); + $updated = company_controller()->updateRecord(company_controller_request('PUT', [ - 'name' => 'Acme Updated', - 'slug' => 'attempted-slug-change', - 'status' => 'suspended', + 'name' => 'Acme Updated', + 'slug' => 'attempted-slug-change', + 'status' => 'suspended', + 'owner_uuid' => 'attempted-owner-takeover', ]), 'company_public_1'); $record = $capsule->getConnection('mysql')->table('companies')->where('uuid', 'company-1')->first(); + // Ownership and lifecycle status are platform-managed and ignored for non-admin updates. expect($updated['company']->resource->name)->toBe('Acme Updated') ->and($record->name)->toBe('Acme Updated') - ->and($record->status)->toBe('suspended') + ->and($record->status)->toBe($before->status) + ->and($record->owner_uuid)->toBe($before->owner_uuid) ->and($record->slug)->toBe('acme-logistics'); $deleted = company_controller()->deleteRecord('company_public_1', company_controller_request('DELETE')); @@ -1427,3 +1440,173 @@ function company_controller_bind_activity(): CompanyControllerActivityFake ->and($notMember->getStatusCode())->toBe(400) ->and($notMember->getData(true))->toBe(['errors' => ['User selected to leave organization is not a member of this organization.']]); }); + +test('company controller only lets organization managers update settings or the organization 2fa policy', function () { + $capsule = company_controller_fixtures(); + + $registered = collect(company_controller()->getMiddleware()) + ->first(fn ($entry) => ($entry['options']['only'] ?? null) === ['updateRecord', 'saveTwoFactorSettings']); + expect($registered)->not->toBeNull(); + + $run = function (string $user) use ($registered) { + session(['company' => 'company-1', 'user' => $user]); + + return ($registered['middleware'])(company_controller_request('PUT'), fn () => 'allowed'); + }; + + // A plain member (the dispatcher case) is refused. + $refused = $run('member-1'); + expect($refused->getStatusCode())->toBe(401) + ->and($refused->getData(true))->toBe(['errors' => ['Only the organization owner or an Administrator can change organization settings.']]); + + // The owner, a member holding the Administrator role, and system admins are allowed. + expect($run('owner-1'))->toBe('allowed') + ->and($run('admin-1'))->toBe('allowed'); + + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-member-1', + ]); + expect($run('member-1'))->toBe('allowed'); +}); + +test('company controller reads and saves organization authentication settings', function () { + $capsule = company_controller_fixtures(); + $activity = company_controller_bind_activity(); + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-owner-1', + ]); + + $defaults = company_controller()->getAuthSettings(); + + expect($defaults->getStatusCode())->toBe(200) + ->and($defaults->getData(true))->toBe(['allow_users_change_password' => true]); + + $saved = company_controller()->saveAuthSettings(company_controller_request('POST', [ + 'allow_users_change_password' => false, + ], company_controller_user('owner-1'))); + + expect($saved->getStatusCode())->toBe(200) + ->and($saved->getData(true))->toBe(['allow_users_change_password' => false]) + ->and(json_decode($capsule->getConnection('mysql')->table('settings')->where('key', 'company.company-1.auth')->value('value'), true))->toBe(['allow_users_change_password' => false]) + ->and(company_controller()->getAuthSettings()->getData(true))->toBe(['allow_users_change_password' => false]) + ->and(array_column($activity->entries, 'event'))->toBe(['auth_settings_updated']); +}); + +test('company controller only lets administrators change organization authentication settings', function () { + $capsule = company_controller_fixtures(); + company_controller_bind_activity(); + + $member = company_controller()->saveAuthSettings(company_controller_request('POST', [ + 'allow_users_change_password' => false, + ], company_controller_user('member-1'))); + $admin = company_controller()->saveAuthSettings(company_controller_request('POST', [ + 'allow_users_change_password' => false, + ], company_controller_user('admin-1'))); + + $missing = company_controller()->saveAuthSettings(company_controller_request('POST', [], company_controller_user('admin-1'))); + + expect($member->getStatusCode())->toBe(403) + ->and($admin->getStatusCode())->toBe(200) + ->and($missing->getStatusCode())->toBe(422) + ->and(json_decode($capsule->getConnection('mysql')->table('settings')->where('key', 'company.company-1.auth')->value('value'), true))->toBe(['allow_users_change_password' => false]); +}); + +it('allows platform administrators to open a foreign organization and returns scoped usage', function () { + $database = company_controller_fixtures()->getConnection('mysql'); + $request = company_controller_admin_request('GET', [], company_controller_user('admin-1')); + $controller = company_controller(); + $found = $controller->findRecord($request, 'company_public_2'); + expect($found['company']->resource->uuid)->toBe('company-2'); + + foreach (['drivers', 'contacts', 'orders', 'api_request_logs', 'webhook_request_logs'] as $tableName) { + $database->getSchemaBuilder()->create($tableName, function ($table) use ($tableName) { + $table->string('company_uuid'); + $table->string('type')->nullable(); + if ($tableName !== 'api_request_logs') { + $table->softDeletes(); + } + }); + $database->table($tableName)->insert([ + ['company_uuid' => 'company-1', 'type' => 'customer'], + ['company_uuid' => 'company-2', 'type' => 'customer'], + ['company_uuid' => 'company-2', 'type' => 'customer'], + ]); + if ($tableName !== 'api_request_logs') { + $database->table($tableName)->insert(['company_uuid' => 'company-2', 'type' => 'customer', 'deleted_at' => '2026-09-01']); + } + } + $database->table('contacts')->insert(['company_uuid' => 'company-2', 'type' => 'contact']); + $database->table('company_users')->insert(['uuid' => 'removed', 'company_uuid' => 'company-2', 'user_uuid' => 'owner-1', 'deleted_at' => '2026-09-01']); + + expect($controller->usage('company_public_2', $request)->getData(true)['usage'])->toBe([ + 'users_count' => 1, + 'drivers_count' => 2, + 'customers_count' => 2, + 'orders_count' => 2, + 'api_requests_count' => 2, + 'webhook_callbacks_count' => 2, + ])->and($controller->usage('missing', $request)->getStatusCode())->toBe(404); + + $database->getSchemaBuilder()->drop('drivers'); + expect($controller->usage('company_public_2', $request)->getData(true)['usage']['drivers_count'])->toBeNull(); +}); + +it('returns non-secret authentication metadata only to organization platform administrators', function () { + $database = company_controller_fixtures()->getConnection('mysql'); + $database->table('settings')->insert([ + 'key' => 'user.owner-1.2fa', + 'value' => json_encode(['enabled' => true, 'method' => 'totp', 'secret' => 'must-not-leak', 'recovery_codes' => ['private']]), + ]); + $database->table('oauth_identities')->insert([ + ['uuid' => 'oauth-1', 'user_uuid' => 'owner-1', 'provider' => 'google', 'provider_user_id' => 'private-google-id', 'meta' => '{"access_token":"private"}'], + ['uuid' => 'oauth-2', 'user_uuid' => 'owner-1', 'provider' => 'github', 'provider_user_id' => 'private-github-id', 'meta' => '{}'], + ['uuid' => 'oauth-3', 'user_uuid' => 'foreign-1', 'provider' => 'microsoft', 'provider_user_id' => 'private-foreign-id', 'meta' => '{}'], + ]); + $controller = company_controller(); + $request = company_controller_request('GET', [], company_controller_user('admin-1')); + $users = $controller->users('company_public_1', $request)->resolve($request); + $owner = collect($users)->firstWhere('uuid', 'owner-1'); + $member = collect($users)->firstWhere('uuid', 'member-1'); + expect($owner)->toMatchArray(['two_factor_enabled' => true, 'two_factor_method' => 'totp', 'oauth_providers' => ['github', 'google']]) + ->and($member)->toMatchArray(['two_factor_enabled' => false, 'two_factor_method' => null, 'oauth_providers' => []]) + ->and(json_encode($users))->not->toContain('must-not-leak', 'recovery_codes', 'access_token', 'provider_user_id', 'microsoft') + ->and($database->table('settings')->where('key', 'user.member-1.2fa')->exists())->toBeFalse(); + + $paginated = $controller->users('company_public_1', company_controller_request('GET', ['paginate' => true], company_controller_user('admin-1')))->getData(true); + expect(collect($paginated['users'])->firstWhere('uuid', 'owner-1')['oauth_providers'])->toBe(['github', 'google']); + + $request = company_controller_request('GET', [], company_controller_user('owner-1')); + $regularUsers = $controller->users('company_public_1', $request)->resolve($request); + expect(collect($regularUsers)->firstWhere('uuid', 'owner-1'))->not->toHaveKey('two_factor_enabled'); + $database->table('company_users')->where('company_uuid', 'company-2')->delete(); + $request = company_controller_request('GET', [], company_controller_user('admin-1')); + expect($controller->users('company_public_2', $request)->resolve($request))->toBe([]); +}); + +it('sorts organizations by current membership count without counting removed users', function () { + $database = company_controller_fixtures()->getConnection('mysql'); + $database->table('companies')->where('uuid', 'company-2')->update(['website_url' => 'https://organization.example.test']); + $database->table('company_users')->insert([ + 'uuid' => 'removed-count', 'company_uuid' => 'company-2', 'user_uuid' => 'owner-1', 'deleted_at' => '2026-09-01', + ]); + $companies = Company::whereIn('uuid', ['company-1', 'company-2'])->withCount('users')->orderBy('users_count', 'desc')->get(); + expect($companies->pluck('uuid')->all())->toBe(['company-1', 'company-2']) + ->and($companies->pluck('users_count')->all())->toBe([2, 1]); + $request = company_controller_request('GET', [], company_controller_user('admin-1')); + $resource = new Fleetbase\Http\Resources\Organization($companies->last()); + expect($resource->resolve($request))->toMatchArray(['users_count' => 1, 'website_url' => 'https://organization.example.test']); +}); + +it('rejects missing sessions before reading or changing organization authentication settings', function () { + company_controller_fixtures(); + session()->remove('company'); + $controller = company_controller(); + expect($controller->getAuthSettings()->getStatusCode())->toBe(401) + ->and($controller->saveAuthSettings(company_controller_request('POST'))->getStatusCode())->toBe(401) + ->and($controller->updateRecord(company_controller_request('PATCH'), 'company_public_1')->getStatusCode())->toBe(404); + + session(['company' => 'company-1']); + session()->remove('user'); + $middleware = collect($controller->getMiddleware())->first(fn ($entry) => ($entry['options']['only'] ?? null) === ['updateRecord', 'saveTwoFactorSettings'])['middleware']; + expect($middleware(company_controller_request('PATCH'), fn () => 'allowed')->getStatusCode())->toBe(401); +}); diff --git a/tests/Unit/Http/ConcreteFilterContractsTest.php b/tests/Unit/Http/ConcreteFilterContractsTest.php index 9256868f..72f85db6 100644 --- a/tests/Unit/Http/ConcreteFilterContractsTest.php +++ b/tests/Unit/Http/ConcreteFilterContractsTest.php @@ -208,6 +208,12 @@ function concrete_filter_database(): Capsule $table->string('public_id')->nullable(); $table->string('name')->nullable(); $table->string('owner_uuid')->nullable(); + $table->string('description')->nullable(); + $table->string('phone')->nullable(); + $table->string('website_url')->nullable(); + $table->string('slug')->nullable(); + $table->string('timezone')->nullable(); + $table->string('type')->nullable(); $table->string('country')->nullable(); $table->string('status')->nullable(); $table->string('plan')->nullable(); @@ -223,6 +229,7 @@ function concrete_filter_database(): Capsule $table->string('email')->nullable(); $table->string('phone')->nullable(); $table->string('type')->nullable(); + $table->string('ip_address')->nullable(); $table->softDeletes(); $table->timestamps(); }); @@ -1205,3 +1212,37 @@ public function isAdmin(): bool ->and(concrete_filter_uuids(UserFilter::class, User::class, ['country' => 'MN'], 'int/v1/users'))->toBe(['user-unverified']) ->and(concrete_filter_uuids(UserFilter::class, User::class, ['timezone' => 'Asia/Singapore'], 'int/v1/users'))->toBe(['user-verified']); }); + +it('searches organization identity and owner details without escaping the tenant scope', function () { + $database = concrete_filter_database()->getConnection('mysql'); + $database->table('users')->insert([ + ['uuid' => 'user-1', 'name' => 'Alice Owner', 'email' => 'alice@example.test', 'phone' => '+14155550123', 'ip_address' => '198.51.100.1'], + ['uuid' => 'other', 'name' => 'Other Owner', 'email' => 'other@foreign.test', 'phone' => '+441234', 'ip_address' => '198.51.100.2'], + ]); + $database->table('companies')->insert([ + ['uuid' => 'owned', 'owner_uuid' => 'user-1', 'name' => 'Fleet Example', 'public_id' => 'company_example', 'phone' => '+14155550199', 'description' => 'Regional delivery', 'website_url' => 'https://example.test', 'slug' => 'fleet-example', 'country' => 'US', 'timezone' => 'America/New_York', 'type' => 'logistics', 'status' => null, 'created_at' => '2026-09-01 23:59:59', 'updated_at' => '2026-09-27 23:59:59'], + ['uuid' => 'foreign', 'owner_uuid' => 'other', 'name' => 'Foreign', 'public_id' => 'company_foreign', 'phone' => null, 'description' => 'Regional delivery', 'website_url' => null, 'slug' => null, 'country' => 'GB', 'timezone' => 'Europe/London', 'type' => 'retail', 'status' => 'suspended', 'created_at' => '2026-08-01 00:00:00', 'updated_at' => '2026-08-02 00:00:00'], + ]); + + foreach (['Alice', 'alice@example.test', '4155550123', '198.51.100.1', 'company_example', '4155550199', 'example.test', 'fleet-example', 'America/New_York'] as $query) { + expect(concrete_filter_admin_uuids(CompanyFilter::class, Company::class, ['query' => $query]))->toBe(['owned']); + } + foreach ([ + ['owner_name' => 'Alice'], ['owner_email' => 'alice@'], ['owner_phone' => '4155550123'], + ['ip_address' => '198.51.100.1'], ['country' => 'us'], ['timezone' => 'America/New_York'], + ['type' => 'logistics'], ['status' => 'active'], + ['created_at_after' => '2026-09-01', 'created_at_before' => '2026-09-01'], + ['updated_at_after' => '2026-09-27'], ['updated_at_before' => '2026-09-27', 'country' => 'US'], + ] as $filters) { + expect(concrete_filter_admin_uuids(CompanyFilter::class, Company::class, $filters))->toBe(['owned']); + } + expect(concrete_filter_admin_uuids(CompanyFilter::class, Company::class, ['created_at_before' => '2026-08-31']))->toBe(['foreign']); + $request = concrete_filter_request(['query' => 'Regional']); + $request->setUserResolver(fn () => new class { + public function isAdmin(): bool + { + return false; + } + }); + expect((new CompanyFilter($request))->apply(Company::query())->pluck('uuid')->all())->toBe(['owned']); +}); diff --git a/tests/Unit/Http/OAuthControllerTest.php b/tests/Unit/Http/OAuthControllerTest.php index 555b3037..21314980 100644 --- a/tests/Unit/Http/OAuthControllerTest.php +++ b/tests/Unit/Http/OAuthControllerTest.php @@ -16,6 +16,7 @@ use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\OAuth\OAuthIdentityService; use Fleetbase\Services\OAuth\OAuthStateService; +use Fleetbase\Support\TwoFactorAuth; use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model as EloquentModel; @@ -174,9 +175,12 @@ class OAuthControllerRedisFake { public array $values = []; + public array $sets = []; + public function set(string $key, mixed $value, mixed ...$options): bool { $this->values[$key] = $value; + $this->sets[] = compact('key', 'value', 'options'); return true; } @@ -1046,7 +1050,12 @@ function oauth_controller_handoff(OAuthController $controller, array $redirectQu expect($data)->not->toHaveKey('token') ->and($data['twoFaSession'])->toBeString() ->and($data['linked'])->toBe('fakeprovider') - ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0) + // The 2FA session is started only after the provider has vouched for the user, + // and lives for a relative TTL in seconds rather than an absolute timestamp. + ->and(app('redis')->sets)->toHaveCount(1) + ->and(app('redis')->sets[0]['key'])->toStartWith('two_fa_session:user-1:') + ->and(app('redis')->sets[0]['options'])->toBe(['EX', TwoFactorAuth::SESSION_TTL]); }); it('asks the user to link by hand when automatic linking does not apply', function (array $account, array $config, ?Closure $before = null) { diff --git a/tests/Unit/Http/ReportControllerTest.php b/tests/Unit/Http/ReportControllerTest.php index 7c961ccd..ea410ddf 100644 --- a/tests/Unit/Http/ReportControllerTest.php +++ b/tests/Unit/Http/ReportControllerTest.php @@ -916,3 +916,21 @@ function report_controller_use_query_validator(ReportController $controller, Rep ->and($current->company_uuid)->toBe('company-1') ->and($other)->toBeNull(); }); + +test('report controller maps direct query execution and exports to the iam report permissions', function () { + report_controller_bind(); + + $controller = new ReportController(); + $scoped = collect($controller->getMiddleware())->mapWithKeys(fn ($entry) => [implode(',', $entry['options']['only'] ?? []) => $entry['middleware']]); + + expect($scoped->keys()->all())->toBe(['executeQuery', 'exportQuery,download']) + ->and($controller->getService())->toBe('iam'); + + // No session user: both are refused before reaching the query engine. + session()->flush(); + $execute = ($scoped['executeQuery'])(Request::create('/int/v1/reports/execute-query', 'POST'), fn () => 'allowed'); + $export = ($scoped['exportQuery,download'])(Request::create('/int/v1/reports/export-query', 'POST'), fn () => 'allowed'); + + expect($execute->getData(true))->toBe(['errors' => ['User is not authorized to execute report']]) + ->and($export->getData(true))->toBe(['errors' => ['User is not authorized to export report']]); +}); diff --git a/tests/Unit/Http/TwoFaControllerTest.php b/tests/Unit/Http/TwoFaControllerTest.php index f9e77ba7..35128a70 100644 --- a/tests/Unit/Http/TwoFaControllerTest.php +++ b/tests/Unit/Http/TwoFaControllerTest.php @@ -31,14 +31,33 @@ public function exists(string $key): bool return array_key_exists($key, $this->values); } - public function del(?string $key): bool + public function get(string $key): mixed { - $this->deleted[] = $key; - unset($this->values[$key]); + return $this->values[$key] ?? null; + } + + public function del(?string ...$keys): bool + { + foreach ($keys as $key) { + $this->deleted[] = $key; + unset($this->values[$key]); + } return true; } + public function incr(string $key): int + { + $this->values[$key] = (int) ($this->values[$key] ?? 0) + 1; + + return $this->values[$key]; + } + + public function expire(string $key, int $seconds): bool + { + return array_key_exists($key, $this->values); + } + public function connection(): self { return $this; @@ -328,27 +347,58 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri ->and(Setting::where('key', 'system.2fa')->value('value'))->not->toBeNull(); }); -test('two fa controller reports enabled sessions only when user level two factor is enabled', function () { - two_fa_controller_database(); +test('two fa controller check never starts a session or reveals whether two factor is enabled', function () { + $redis = two_fa_controller_database(); $user = two_fa_controller_user(); $controller = two_fa_controller(); - $disabled = $controller->checkTwoFactor(Request::create('/int/v1/two-fa/check', 'POST', [ + $disabled = $controller->checkTwoFactor(Request::create('/int/v1/two-fa/check', 'GET', [ 'identity' => $user->email, ])); TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); - $enabled = $controller->checkTwoFactor(Request::create('/int/v1/two-fa/check', 'POST', [ + $enabled = $controller->checkTwoFactor(Request::create('/int/v1/two-fa/check', 'GET', [ 'identity' => $user->email, ])); + $missing = $controller->checkTwoFactor(Request::create('/int/v1/two-fa/check', 'GET', [ + 'identity' => 'missing@example.test', + ])); - expect($disabled->getData(true))->toBe([ - 'twoFaSession' => null, - 'isTwoFaEnabled' => false, - ]) - ->and($enabled->getData(true)['isTwoFaEnabled'])->toBeTrue() - ->and($enabled->getData(true)['twoFaSession'])->toBeString() - ->and($enabled->getData(true)['twoFaSession'])->not->toContain('two_fa_session'); + // A 2FA session is only started by auth/login once the password has been checked. + $expected = ['twoFaSession' => null, 'isTwoFaEnabled' => false]; + + expect($disabled->getData(true))->toBe($expected) + ->and($enabled->getData(true))->toBe($expected) + ->and($missing->getData(true))->toBe($expected) + ->and($redis->values)->toBe([]); +}); + +test('two fa controller verify invalidates the session after too many wrong codes', function () { + $redis = two_fa_controller_database(); + $user = two_fa_controller_user(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + $token = TwoFactorAuth::start($user->email, 10); + $verificationCode = two_fa_controller_verification_code($user, Carbon::now()->addMinutes(5)); + $clientToken = TwoFactorAuth::createClientSessionToken($verificationCode); + + $verify = fn (string $code) => two_fa_controller()->verifyCode(Request::create('/int/v1/two-fa/verify', 'POST', [ + 'code' => $code, + 'token' => $token, + 'clientToken' => $clientToken, + ])); + + $failures = []; + for ($i = 0; $i < TwoFactorAuth::MAX_VERIFY_ATTEMPTS; $i++) { + $failures[] = $verify('000000')->getData(true); + } + $afterLockout = $verify('123456'); + + expect(array_slice($failures, 0, -1))->each->toBe(['errors' => ['Verification code does not match.']]) + ->and(end($failures))->toBe(['errors' => ['Too many failed verification attempts. Please sign in again.']]) + ->and($afterLockout->getStatusCode())->toBe(400) + ->and($afterLockout->getData(true))->toBe(['errors' => ['Verification code is invalid.']]) + ->and($redis->values)->toBe([]) + ->and(app('db')->table('personal_access_tokens')->count())->toBe(0); }); test('two fa controller validates sessions returning existing client tokens expired states and errors', function () { @@ -378,6 +428,7 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri expect($valid->getData(true))->toBe([ 'clientToken' => $clientToken, + 'method' => 'email', 'expired' => false, ]) ->and($expired->getData(true))->toBe(['expired' => true]) @@ -469,3 +520,37 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(0); }); + +test('two fa controller restricts the system-wide policy save to system administrators', function () { + two_fa_controller_database(); + + $middleware = two_fa_controller()->getMiddleware(); + expect($middleware)->toHaveCount(1) + ->and($middleware[0]['options']['only'])->toBe(['saveSystemConfig']); + + session()->flush(); + $refused = ($middleware[0]['middleware'])(Request::create('/int/v1/two-fa/config', 'POST'), fn () => 'allowed'); + + expect($refused->getStatusCode())->toBe(401) + ->and($refused->getData(true))->toBe(['errors' => ['Only system administrators can change the system two-factor policy.']]); +}); + +test('two fa controller tells the console when to ask for a code from the authenticator app', function () { + two_fa_controller_database(); + $user = two_fa_controller_user(); + $enrollment = @TwoFactorAuth::beginAuthenticatorEnrollment($user); + TwoFactorAuth::confirmAuthenticatorEnrollment($user, (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($enrollment['secret'])); + $token = TwoFactorAuth::start($user); + + $challenge = two_fa_controller()->validateSession(two_fa_controller_validation_request([ + 'token' => $token, + 'identity' => $user->email, + ])); + $fallback = two_fa_controller()->resendCode(Request::create('/int/v1/two-fa/resend', 'POST', [ + 'identity' => $user->email, + 'token' => $token, + ])); + + expect($challenge->getData(true))->toMatchArray(['method' => 'authenticator_app', 'expired' => false]) + ->and($fallback->getData(true)['method'])->toBe('email'); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index bdc128c2..44fce37e 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Internal\v1\UserController; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\Internal\AcceptCompanyInvite; +use Fleetbase\Http\Requests\Internal\ChangeCurrentPasswordRequest; use Fleetbase\Http\Requests\Internal\ChangeCurrentUserEmailRequest; use Fleetbase\Http\Requests\Internal\ChangeUserEmailRequest; use Fleetbase\Http\Requests\Internal\InviteUserRequest; @@ -13,6 +14,8 @@ use Fleetbase\Http\Requests\Internal\ValidatePasswordRequest; use Fleetbase\Models\Role; use Fleetbase\Models\User; +use Fleetbase\Support\Auth; +use Fleetbase\Support\TwoFactorAuth; use Illuminate\Container\Container; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; @@ -24,6 +27,8 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\Assert; +use Spatie\Activitylog\ActivityLogger; +use Spatie\Activitylog\PendingActivityLog; if (!function_exists('base_path')) { function base_path(string $path = ''): string @@ -324,6 +329,73 @@ public function getSingularName(): string } } +class UserControllerActivityLoggerFake extends ActivityLogger +{ + public static array $logged = []; + + private array $entry = []; + + public function __construct(private ?string $logName = null) + { + } + + public function causedBy(EloquentModel|int|string|null $modelOrId): static + { + $this->entry['causer'] = $modelOrId instanceof EloquentModel ? $modelOrId->getKey() : $modelOrId; + + return $this; + } + + public function performedOn(EloquentModel $model): static + { + $this->entry['subject'] = $model->getKey(); + + return $this; + } + + public function event(string $event): static + { + $this->entry['event'] = $event; + + return $this; + } + + public function withProperties(mixed $properties): static + { + $this->entry['properties'] = $properties; + + return $this; + } + + public function log(string $description): ?Spatie\Activitylog\Contracts\Activity + { + static::$logged[] = array_merge(['log' => $this->logName, 'description' => $description], $this->entry); + + return null; + } +} + +class UserControllerPendingActivityLogFake extends PendingActivityLog +{ + private ?string $logName = null; + + public function __construct() + { + } + + public function useLog(?string $logName): static + { + $this->logName = $logName; + + return $this; + } + + public function logger(): ActivityLogger + { + return new UserControllerActivityLoggerFake($this->logName); + } +} + function user_controller_database(): Capsule { EloquentModel::clearBootedModels(); @@ -602,6 +674,9 @@ public function clear(): void ['id' => 'Administrator', 'company_uuid' => null, 'name' => 'Administrator', 'guard_name' => 'sanctum', 'created_at' => $now, 'updated_at' => $now], ]); + UserControllerActivityLoggerFake::$logged = []; + app()->instance(PendingActivityLog::class, new UserControllerPendingActivityLogFake()); + return $capsule; } @@ -1355,22 +1430,17 @@ function user_controller_assert_created_user_response(mixed $response): object|a $missingPassword = user_controller()->setCurrentUserPassword(user_controller_request('POST', [ 'password' => 'new-password', ], null, 'setCurrentUserPassword', UpdatePasswordRequest::class)); - $passwordMismatch = user_controller()->changeUserPassword(user_controller_request('POST', [ - 'password' => 'new-password', - 'password_confirmation' => 'different-password', - ], $user, 'changeUserPassword', UpdatePasswordRequest::class)); expect($missingCurrent->getStatusCode())->toBe(401) ->and($missingCurrent->getData(true))->toBe(['errors' => ['No user session found']]) ->and($missingPassword->getStatusCode())->toBe(400) - ->and($missingPassword->getData(true))->toBe(['errors' => ['User not authenticated']]) - ->and($passwordMismatch->getStatusCode())->toBe(400) - ->and($passwordMismatch->getData(true))->toBe(['errors' => ['Password is not matching']]); + ->and($missingPassword->getData(true))->toBe(['errors' => ['User not authenticated']]); $changedPassword = user_controller()->changeUserPassword(user_controller_request('POST', [ + 'current_password' => 'old-password', 'password' => 'new-password', 'password_confirmation' => 'new-password', - ], $user, 'changeUserPassword', UpdatePasswordRequest::class)); + ], $user, 'changeUserPassword', ChangeCurrentPasswordRequest::class)); $setLocale = user_controller()->setUserLocale(user_controller_request('POST', [ 'locale' => 'fr-fr', ], $user)); @@ -1573,6 +1643,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a 'model_uuid' => 'pivot-owner-1', ]); + Auth::markPasswordSetupPending($user); + $setPassword = user_controller()->setCurrentUserPassword(user_controller_request('POST', [ 'password' => 'current-new-password', ], $user, 'setCurrentUserPassword', UpdatePasswordRequest::class)); @@ -1798,6 +1870,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a expect($accepted->getStatusCode())->toBe(200) ->and($accepted->getData(true)['status'])->toBe('ok') ->and($accepted->getData(true)['needs_password'])->toBeTrue() + ->and(Auth::isPasswordSetupPending($pending))->toBeTrue() ->and($accepted->getData(true)['token'])->toContain('|') ->and($capsule->getConnection('mysql')->table('company_users')->where('company_uuid', 'company-1')->where('user_uuid', 'pending-1')->exists())->toBeTrue() ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'pending-1')->value('company_uuid'))->toBe('company-1') @@ -2214,3 +2287,291 @@ function user_controller_managed_account(Capsule $capsule, array $attributes = [ 'a driver account' => [['type' => 'driver'], null], 'an account promoted by an operator' => [['type' => 'user'], ['promoted_from' => 'contact']], ]); + +function user_controller_disallow_changing_own_password(Capsule $capsule, string $companyUuid = 'company-1'): void +{ + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'company.' . $companyUuid . '.auth', + 'value' => json_encode(['allow_users_change_password' => false]), + ]); +} + +function user_controller_grant_permission(Capsule $capsule, string $companyUserUuid, string $permission): void +{ + $id = 'permission-' . Illuminate\Support\Str::slug($permission); + if ($capsule->getConnection('mysql')->table('permissions')->where('id', $id)->doesntExist()) { + $capsule->getConnection('mysql')->table('permissions')->insert([ + 'id' => $id, + 'name' => $permission, + 'guard_name' => 'sanctum', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $capsule->getConnection('mysql')->table('model_has_permissions')->insert([ + 'permission_id' => $id, + 'model_type' => Fleetbase\Models\CompanyUser::class, + 'model_uuid' => $companyUserUuid, + ]); +} + +test('user controller self-service password endpoints skip the generic resource permission check', function (string $method) { + user_controller_database(); + + $attributes = (new ReflectionMethod(UserController::class, $method))->getAttributes(Fleetbase\Attributes\SkipAuthorizationCheck::class); + + expect($attributes)->toHaveCount(1); +})->with(['setCurrentUserPassword', 'validatePassword', 'changeUserPassword', 'getPasswordPolicy']); + +test('user controller lets an invited non-admin set their first password only once', function () { + $capsule = user_controller_database(); + session(['company' => 'company-1']); + $member = user_controller_user('member-1'); + + $request = fn (string $password) => user_controller_request('POST', [ + 'password' => $password, + 'password_confirmation' => $password, + ], $member, 'setCurrentUserPassword', UpdatePasswordRequest::class); + + $withoutAllowance = user_controller()->setCurrentUserPassword($request('First-password-1!')); + + Auth::markPasswordSetupPending($member); + $firstPassword = user_controller()->setCurrentUserPassword($request('First-password-1!')); + $secondAttempt = user_controller()->setCurrentUserPassword($request('Second-password-1!')); + + expect($withoutAllowance->getStatusCode())->toBe(403) + ->and($firstPassword->getStatusCode())->toBe(200) + ->and($firstPassword->getData(true))->toBe(['status' => 'ok']) + ->and($secondAttempt->getStatusCode())->toBe(403) + ->and(Auth::isPasswordSetupPending($member))->toBeFalse() + ->and(UserControllerActivityLoggerFake::$logged)->toBe([ + ['log' => 'auth', 'description' => 'Password set', 'causer' => 'member-1', 'subject' => 'member-1', 'event' => 'password_set'], + ]) + ->and(password_verify('First-password-1!', $capsule->getConnection('mysql')->table('users')->where('uuid', 'member-1')->value('password')))->toBeTrue(); +}); + +test('user controller password setup allowance expires', function () { + user_controller_database(); + $member = user_controller_user('member-1'); + + Carbon::setTestNow('2026-09-26 10:00:00'); + Auth::markPasswordSetupPending($member, 24); + + Carbon::setTestNow('2026-09-27 09:59:00'); + expect(Auth::isPasswordSetupPending($member))->toBeTrue(); + + Carbon::setTestNow('2026-09-27 10:01:00'); + $expired = user_controller()->setCurrentUserPassword(user_controller_request('POST', [ + 'password' => 'Late-password-1!', + ], $member, 'setCurrentUserPassword', UpdatePasswordRequest::class)); + + expect($expired->getStatusCode())->toBe(403); +}); + +test('user controller lets non-admins change their own password when the organization allows it', function () { + $capsule = user_controller_database(); + session(['company' => 'company-1']); + $member = user_controller_user('member-1'); + + $policy = user_controller()->getPasswordPolicy(user_controller_request('GET', [], $member, 'getPasswordPolicy')); + $changed = user_controller()->changeUserPassword(user_controller_request('POST', [ + 'current_password' => 'old-password', + 'password' => 'Changed-password-1!', + 'password_confirmation' => 'Changed-password-1!', + ], $member, 'changeUserPassword', ChangeCurrentPasswordRequest::class)); + + expect($policy->getData(true))->toBe(['can_change_password' => true]) + ->and($changed->getStatusCode())->toBe(200) + ->and(array_column(UserControllerActivityLoggerFake::$logged, 'event'))->toBe(['password_changed']) + ->and(password_verify('Changed-password-1!', $capsule->getConnection('mysql')->table('users')->where('uuid', 'member-1')->value('password')))->toBeTrue(); +}); + +test('user controller requires the change-password permission when the organization disallows changing own password', function () { + $capsule = user_controller_database(); + session(['company' => 'company-1']); + user_controller_disallow_changing_own_password($capsule); + $member = user_controller_user('member-1'); + + $request = fn () => user_controller_request('POST', [ + 'current_password' => 'old-password', + 'password' => 'Changed-password-1!', + 'password_confirmation' => 'Changed-password-1!', + ], $member, 'changeUserPassword', ChangeCurrentPasswordRequest::class); + + $denied = user_controller()->changeUserPassword($request()); + $policy = user_controller()->getPasswordPolicy(user_controller_request('GET', [], $member, 'getPasswordPolicy')); + + expect($denied->getStatusCode())->toBe(403) + ->and($policy->getData(true))->toBe(['can_change_password' => false]) + ->and(password_verify('old-password', $capsule->getConnection('mysql')->table('users')->where('uuid', 'member-1')->value('password')))->toBeTrue(); + + user_controller_grant_permission($capsule, 'pivot-member-1', 'iam change-password'); + $member = user_controller_user('member-1'); + $allowed = user_controller()->changeUserPassword($request()); + + expect($allowed->getStatusCode())->toBe(200); +}); + +test('user controller always lets admins and administrators change their own password', function () { + $capsule = user_controller_database(); + session(['company' => 'company-1']); + user_controller_disallow_changing_own_password($capsule); + user_controller_owner_is_administrator($capsule); + + expect(Auth::canChangeOwnPassword(user_controller_user('owner-1')))->toBeTrue() + ->and(Auth::canChangeOwnPassword(user_controller_user('admin-1')))->toBeTrue() + ->and(Auth::canChangeOwnPassword(user_controller_user('member-1')))->toBeFalse(); +}); + +test('user controller accepts the iam wildcard permission for changing own password', function () { + $capsule = user_controller_database(); + session(['company' => 'company-1']); + user_controller_disallow_changing_own_password($capsule); + user_controller_grant_permission($capsule, 'pivot-member-1', 'iam *'); + + expect(Auth::canChangeOwnPassword(user_controller_user('member-1')))->toBeTrue(); +}); + +test('change current password request checks the current password in the same request', function () { + user_controller_database(); + $member = user_controller_user('member-1'); + $request = ChangeCurrentPasswordRequest::create('/int/v1/users/change-password', 'POST'); + $request->setUserResolver(fn () => $member); + + $rules = ['current_password' => $request->rules()['current_password']]; + + $translator = new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'); + $validate = fn (array $input) => new Illuminate\Validation\Validator($translator, $input, $rules); + + $wrong = $validate(['current_password' => 'not-my-password']); + $right = $validate(['current_password' => 'old-password']); + $none = $validate([]); + + $missing = new Illuminate\Validation\Validator($translator, [], $rules, $request->messages()); + + expect($request->authorize())->toBeTrue() + ->and($missing->fails())->toBeTrue() + ->and($missing->errors()->first('current_password'))->toBe('The current password is required.') + ->and($request->messages()['password.uncompromised'])->toContain('data breach') + ->and($wrong->fails())->toBeTrue() + ->and($wrong->errors()->first('current_password'))->toBe('The current password provided is invalid.') + ->and($right->fails())->toBeFalse() + ->and($none->fails())->toBeTrue(); +}); + +test('user controller authenticator app endpoints skip the generic resource permission check', function (string $method) { + user_controller_database(); + + expect((new ReflectionMethod(UserController::class, $method))->getAttributes(Fleetbase\Attributes\SkipAuthorizationCheck::class))->toHaveCount(1); +})->with(['getAuthenticatorApp', 'setupAuthenticatorApp', 'confirmAuthenticatorApp', 'disableAuthenticatorApp', 'regenerateRecoveryCodes']); + +test('user controller sets up an authenticator app after checking the current password', function () { + user_controller_database(); + $user = user_controller_user('owner-1'); + + $wrongPassword = user_controller()->setupAuthenticatorApp(user_controller_request('POST', ['password' => 'nope'], $user, 'setupAuthenticatorApp')); + $setup = @user_controller()->setupAuthenticatorApp(user_controller_request('POST', ['password' => 'old-password'], $user, 'setupAuthenticatorApp')); + $secret = $setup->getData(true)['secret']; + $wrongCode = user_controller()->confirmAuthenticatorApp(user_controller_request('POST', ['code' => '000000'], $user, 'confirmAuthenticatorApp')); + $confirmed = user_controller()->confirmAuthenticatorApp(user_controller_request('POST', [ + 'code' => (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($secret), + ], $user, 'confirmAuthenticatorApp')); + + expect($wrongPassword->getStatusCode())->toBe(422) + ->and($setup->getStatusCode())->toBe(200) + ->and(array_keys($setup->getData(true)))->toBe(['secret', 'otpauth_url', 'qr_code', 'expires_at']) + ->and($wrongCode->getStatusCode())->toBe(422) + ->and($wrongCode->getData(true))->toBe(['errors' => ['The code from your authenticator app is not correct.']]) + ->and($confirmed->getStatusCode())->toBe(200) + ->and($confirmed->getData(true)['recovery_codes'])->toHaveCount(8) + ->and($confirmed->getData(true)['status'])->toMatchArray(['enabled' => true, 'recovery_codes_remaining' => 8]) + ->and($confirmed->getData(true)['settings'])->toBe(['enabled' => true, 'method' => 'authenticator_app']) + ->and(user_controller()->getAuthenticatorApp(user_controller_request('GET', [], $user, 'getAuthenticatorApp'))->getData(true)['enabled'])->toBeTrue(); +}); + +test('user controller only accepts the authenticator app as the two factor method once it is set up', function () { + user_controller_database(); + $user = user_controller_user('owner-1'); + + $rejected = user_controller()->saveTwoFactorSettings(user_controller_request('POST', [ + 'twoFaSettings' => ['enabled' => true, 'method' => 'authenticator_app'], + ], $user, 'saveTwoFactorSettings')); + + expect($rejected->getStatusCode())->toBe(422) + ->and(TwoFactorAuth::getTwoFaSettingsForUser($user)->value)->toBe(['enabled' => false, 'method' => 'email']); +}); + +test('user controller disables the authenticator app and replaces recovery codes only with the current password', function () { + user_controller_database(); + $user = user_controller_user('owner-1'); + $enrollment = @TwoFactorAuth::beginAuthenticatorEnrollment($user); + TwoFactorAuth::confirmAuthenticatorEnrollment($user, (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($enrollment['secret'])); + + $codesDenied = user_controller()->regenerateRecoveryCodes(user_controller_request('POST', ['password' => 'nope'], $user, 'regenerateRecoveryCodes')); + $codes = user_controller()->regenerateRecoveryCodes(user_controller_request('POST', ['password' => 'old-password'], $user, 'regenerateRecoveryCodes')); + $denied = user_controller()->disableAuthenticatorApp(user_controller_request('POST', ['password' => 'nope'], $user, 'disableAuthenticatorApp')); + $disabled = user_controller()->disableAuthenticatorApp(user_controller_request('POST', ['password' => 'old-password'], $user, 'disableAuthenticatorApp')); + $noApp = user_controller()->regenerateRecoveryCodes(user_controller_request('POST', ['password' => 'old-password'], $user, 'regenerateRecoveryCodes')); + + expect($codesDenied->getStatusCode())->toBe(422) + ->and($codes->getData(true)['recovery_codes'])->toHaveCount(8) + ->and($denied->getStatusCode())->toBe(422) + ->and(array_column(UserControllerActivityLoggerFake::$logged, 'event'))->toBe(['authenticator_enabled', 'recovery_codes_regenerated', 'authenticator_disabled']) + ->and($disabled->getData(true))->toBe([ + 'status' => ['enabled' => false, 'confirmed_at' => null, 'recovery_codes_remaining' => 0], + 'settings' => ['enabled' => false, 'method' => 'email'], + ]) + ->and($noApp->getStatusCode())->toBe(422); +}); + +it('enforces metrics and system two-factor permissions before reaching their handlers', function (string $controllerClass, ?string $permission) { + $database = user_controller_database(); + $controller = new $controllerClass(); + $middleware = collect($controller->getMiddleware())->first(fn ($entry) => $entry['middleware'] instanceof Closure)['middleware']; + $run = function (?string $actor) use ($middleware) { + session()->remove('user'); + if ($actor !== null) { + session(['user' => $actor]); + } + $request = user_controller_request('GET', [], $actor ? user_controller_user($actor) : null); + + return $middleware($request, fn () => 'allowed'); + }; + + expect($run(null)->getStatusCode())->toBe(401) + ->and($run('member-1')->getStatusCode())->toBe(401) + ->and($run('admin-1'))->toBe('allowed'); + + if ($permission !== null) { + user_controller_grant_permission($database, 'pivot-member-1', $permission); + expect($run('member-1'))->toBe('allowed'); + } +})->with([ + 'platform metrics' => [Fleetbase\Http\Controllers\Internal\v1\AdminMetricsController::class, null], + 'developer metrics' => [Fleetbase\Http\Controllers\Internal\v1\DeveloperMetricsController::class, 'developers list api-key'], + 'IAM metrics' => [Fleetbase\Http\Controllers\Internal\v1\IamMetricsController::class, 'iam list user'], + 'system two-factor policy' => [Fleetbase\Http\Controllers\Internal\v1\TwoFaController::class, null], +]); + +it('allows report execution and export only after the matching permission check', function (string $action, string $method) { + $database = user_controller_database(); + $controller = new Fleetbase\Http\Controllers\Internal\v1\ReportController(); + $middleware = collect($controller->getMiddleware())->first(fn ($entry) => in_array($method, $entry['options']['only'] ?? [], true))['middleware']; + $run = function (string $actor) use ($middleware, $method) { + session(['user' => $actor]); + $request = user_controller_request('POST', [], user_controller_user($actor), $method); + + return $middleware($request, fn () => 'allowed'); + }; + + expect($run('member-1')->getStatusCode())->toBe(401) + ->and($run('admin-1'))->toBe('allowed'); + user_controller_grant_permission($database, 'pivot-member-1', 'iam ' . $action . ' report'); + expect($run('member-1'))->toBe('allowed'); +})->with([['execute', 'executeQuery'], ['export', 'exportQuery'], ['export', 'download']]); + +it('uses the API key schema name when authorizing credential operations', function () { + user_controller_database(); + expect((new Fleetbase\Http\Controllers\Internal\v1\ApiCredentialController())->getPermissionResourceName())->toBe('api-key'); +}); diff --git a/tests/Unit/Models/SettingModelTest.php b/tests/Unit/Models/SettingModelTest.php index e6df5fab..4199abcb 100644 --- a/tests/Unit/Models/SettingModelTest.php +++ b/tests/Unit/Models/SettingModelTest.php @@ -329,6 +329,19 @@ function setting_model_database(): array ->and(Setting::getByKey('company.' . $companyUuid . '.dispatch.enabled'))->toBeInstanceOf(Setting::class); }); +it('looks up company settings by company uuid without a session', function () { + setting_model_database(); + session()->flush(); + + $companyUuid = '8b5cc964-2d67-4d9f-8b5d-0aa3070a5b5d'; + Setting::configure('company.' . $companyUuid . '.dispatch.enabled', true); + + expect(Setting::lookupForCompany($companyUuid, 'dispatch.enabled', false))->toBeTrue() + ->and(Setting::lookupForCompany($companyUuid, 'missing.key', 'fallback'))->toBe('fallback') + ->and(Setting::lookupForCompany(null, 'dispatch.enabled', 'fallback'))->toBe('fallback') + ->and(Setting::lookupCompany('dispatch.enabled', 'default'))->toBe('default'); +}); + it('exposes JSON value helpers and database connection checks', function () { setting_model_database(); diff --git a/tests/Unit/NotificationsAndMailTest.php b/tests/Unit/NotificationsAndMailTest.php index 35e1e286..5787b8c8 100644 --- a/tests/Unit/NotificationsAndMailTest.php +++ b/tests/Unit/NotificationsAndMailTest.php @@ -375,6 +375,7 @@ function notification_chat_channel(array $attributes = []): ChatChannel 'body' => 'Test body', ], 'android' => [ + 'priority' => 'high', 'notification' => [ 'color' => '#4391EA', 'sound' => 'default', diff --git a/tests/Unit/Support/AuthSupportTest.php b/tests/Unit/Support/AuthSupportTest.php index 4aa4c7cd..cd425cff 100644 --- a/tests/Unit/Support/AuthSupportTest.php +++ b/tests/Unit/Support/AuthSupportTest.php @@ -221,6 +221,30 @@ public function queryRecord(): void } } +class AuthSupportAliasedResourceController +{ + public string $permissionResource = 'api-key'; + + public function getService(): string + { + return 'developers'; + } + + public function getResourceSingularName(): string + { + return 'api_credential'; + } + + public function getPermissionResourceName(): string + { + return $this->permissionResource; + } + + public function queryRecord(): void + { + } +} + class AuthSupportSkipController { #[SkipAuthorizationCheck] @@ -787,6 +811,41 @@ function auth_support_request(string $method = 'GET', ?string $controllerClass = expect(Auth::resolvePermissionsFromRequest(auth_support_request('GET', AuthSupportSkipController::class))->isEmpty())->toBeTrue(); }); +test('auth support resolves permissions against a controller permission resource alias', function () { + [$admin] = auth_support_fixtures(); + session(['user' => $admin->uuid]); + + app('db')->table('permissions')->insert([ + ['id' => 'permission-list-api-key', 'name' => 'developers list api-key', 'guard_name' => 'sanctum', 'service' => 'developers', 'created_at' => now(), 'updated_at' => now()], + ]); + + $request = auth_support_request('GET', AuthSupportAliasedResourceController::class); + + // Without the alias the model name "api-credential" matches no permission and the guard lets everyone through. + expect(Auth::isResourceGuarded('api-credential'))->toBeFalse() + ->and(Auth::getRequiredPermissionNameFromRequest($request))->toBe('list api-key') + ->and(Auth::resolvePermissionsFromRequest($request)->pluck('id')->all())->toBe(['permission-list-api-key']) + ->and(Auth::getPermissionResourceFromController(app(AuthSupportResourceController::class)))->toBe('user'); +}); + +test('auth support cannotUnlessAdmin lets platform admins through and checks everyone else', function () { + [$admin] = auth_support_fixtures(); + + app('db')->table('permissions')->insert([ + ['id' => 'permission-list-user-admin-check', 'name' => 'iam list user', 'guard_name' => 'sanctum', 'service' => 'iam', 'created_at' => now(), 'updated_at' => now()], + ]); + + session(['user' => $admin->uuid]); + $admin->forceFill(['type' => 'admin'])->save(); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeFalse(); + + $admin->forceFill(['type' => 'user'])->save(); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeTrue(); + + session(['user' => null]); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeTrue(); +}); + test('auth support filters and applies directives for assigned role and policy subjects', function () { [$admin] = auth_support_fixtures(); session(['user' => $admin->uuid]); diff --git a/tests/Unit/Support/BarcodeTest.php b/tests/Unit/Support/BarcodeTest.php new file mode 100644 index 00000000..f3419176 --- /dev/null +++ b/tests/Unit/Support/BarcodeTest.php @@ -0,0 +1,59 @@ + 3, + 'num_cols' => 3, + 'bcode' => [ + [1, 1, 0], + [0, 0, 0], + [1, 0, 1], + ], + ]; + } +} + +test('barcode renders qr modules as merged runs on a white background with a quiet zone', function () { + expect(BarcodeFixedMatrix::qrCodeSvg('anything', 'M', 1))->toBe( + '' + . '' + . '' + . '' + ); +}); + +test('barcode encodes real qr codes with the standard quiet zone', function () { + $url = 'otpauth://totp/Fleetbase:user%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Fleetbase'; + $svg = @Barcode::qrCodeSvg($url); + + preg_match('/viewBox="0 0 (\d+) (\d+)"/', $svg, $size); + + // A QR code is 21 + 4n modules square, plus 4 blank modules on each side + expect((int) $size[1])->toBe((int) $size[2]) + ->and(((int) $size[1] - 8 - 21) % 4)->toBe(0) + ->and($svg)->toContain('fill="#ffffff"') + ->and($svg)->toContain(' Barcode::qrCodeSvg(''))->toThrow(InvalidArgumentException::class, 'The data could not be encoded as a QR code.') + ->and(fn () => Barcode::qrCodeSvg('data', 'X'))->toThrow(InvalidArgumentException::class, 'QR code error correction must be L, M, Q or H.'); +}); + +test('barcode renders png barcodes through milon', function () { + $png = @Barcode::png('order-123', 'QRCODE'); + + expect($png)->toBeString() + ->and(substr(base64_decode($png), 1, 3))->toBe('PNG'); +})->skip(!function_exists('imagecreate') && !extension_loaded('imagick'), 'needs GD or Imagick'); diff --git a/tests/Unit/Support/NotificationRegistryTest.php b/tests/Unit/Support/NotificationRegistryTest.php index 665debb9..4bfd0d96 100644 --- a/tests/Unit/Support/NotificationRegistryTest.php +++ b/tests/Unit/Support/NotificationRegistryTest.php @@ -381,7 +381,7 @@ function notification_registry_dispatch_database(): Capsule notification_registry_dispatch_database(); Fleetbase\Models\Setting::query()->create([ - 'key' => 'notification_settings', + 'key' => 'company.company-1.notification_settings', 'value' => [ 'notificationRegistryDispatchNotification__manualNotice' => [ 'notifiables' => [ @@ -400,8 +400,9 @@ function notification_registry_dispatch_database(): Capsule ], ]); - $subject = new NotificationRegistryDispatchSubject(); - $subject->uuid = 'subject-2'; + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-2'; + $subject->company_uuid = 'company-1'; NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'manual'); @@ -425,7 +426,7 @@ function notification_registry_dispatch_database(): Capsule notification_registry_dispatch_database(); Fleetbase\Models\Setting::query()->create([ - 'key' => 'notification_settings', + 'key' => 'company.company-1.notification_settings', 'value' => [ 'notificationRegistryDispatchNotification__manualNotice' => [ 'notifiables' => [ @@ -439,8 +440,9 @@ function notification_registry_dispatch_database(): Capsule ], ]); - $subject = new NotificationRegistryDispatchPropertySubject(); - $subject->uuid = 'subject-3'; + $subject = new NotificationRegistryDispatchPropertySubject(); + $subject->uuid = 'subject-3'; + $subject->company_uuid = 'company-1'; NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'property'); @@ -505,7 +507,7 @@ function notification_registry_dispatch_database(): Capsule notification_registry_dispatch_database(); Fleetbase\Models\Setting::query()->create([ - 'key' => 'notification_settings', + 'key' => 'company.company-1.notification_settings', 'value' => [ 'notificationRegistryDispatchNotification__manualNotice' => [ 'notifiables' => [ @@ -519,8 +521,9 @@ function notification_registry_dispatch_database(): Capsule ], ]); - $subject = new NotificationRegistryDispatchPropertySubject(); - $subject->uuid = 'subject-5'; + $subject = new NotificationRegistryDispatchPropertySubject(); + $subject->uuid = 'subject-5'; + $subject->company_uuid = 'company-1'; $subject->setRelation('assignee', NotificationRegistryDispatchTarget::query()->find('target-2')); NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'property'); @@ -539,7 +542,7 @@ function notification_registry_dispatch_database(): Capsule notification_registry_dispatch_database(); Fleetbase\Models\Setting::query()->create([ - 'key' => 'notification_settings', + 'key' => 'company.company-1.notification_settings', 'value' => [ 'notificationRegistryDispatchNotification__manualNotice' => [ 'notifiables' => [ @@ -553,10 +556,143 @@ function notification_registry_dispatch_database(): Capsule ], ]); - $subject = new NotificationRegistryDispatchSubject(); - $subject->uuid = 'subject-6'; + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-6'; + $subject->company_uuid = 'company-1'; NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'ignored'); expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); }); + +function notification_registry_company_settings(string $companyUuid, string $settingsKey, string $targetUuid = 'target-1'): void +{ + Fleetbase\Models\Setting::query()->create([ + 'key' => 'company.' . $companyUuid . '.notification_settings', + 'value' => [ + $settingsKey => [ + 'notifiables' => [ + [ + 'definition' => NotificationRegistryDispatchTarget::class, + 'primaryKey' => 'uuid', + 'key' => $targetUuid, + ], + ], + ], + ], + ]); +} + +test('notification registry resolves company settings from the subject when there is no session', function () { + notification_registry_dispatch_database(); + NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + notification_registry_company_settings('company-1', 'notificationRegistryDispatchNotification__dispatchNotice'); + + // Queued listeners and console commands run without a company session + expect(session()->missing('company'))->toBeTrue(); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-queued'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notify(NotificationRegistryDispatchNotification::class, $subject, 'queued'); + + expect(NotificationRegistryDispatchTarget::$sent)->toBe([ + [ + 'target' => 'target-1', + 'notification' => NotificationRegistryDispatchNotification::class, + 'subject' => 'subject-queued', + 'label' => 'queued', + ], + ]); +}); + +test('notification registry prefers the subject company over the session company', function () { + notification_registry_dispatch_database(); + session(['company' => 'company-2']); + NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + notification_registry_company_settings('company-1', 'notificationRegistryDispatchNotification__dispatchNotice', 'target-1'); + notification_registry_company_settings('company-2', 'notificationRegistryDispatchNotification__dispatchNotice', 'target-2'); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-company'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notify(NotificationRegistryDispatchNotification::class, $subject, 'subject-company'); + + expect(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-1']); +}); + +test('notification registry resolves company settings from a company parameter', function () { + notification_registry_dispatch_database(); + NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + notification_registry_company_settings('company-1', 'notificationRegistryDispatchNotification__dispatchNotice'); + + $company = new Fleetbase\Models\Company(); + $company->uuid = 'company-1'; + + NotificationRegistry::notify(NotificationRegistryDispatchNotification::class, $company, 'company'); + + expect(NotificationRegistryDispatchTarget::$sent)->toBe([ + [ + 'target' => 'target-1', + 'notification' => NotificationRegistryDispatchNotification::class, + 'subject' => 'company-1', + 'label' => 'company', + ], + ]); +}); + +test('notification registry falls back to the session company when the subject has none', function () { + notification_registry_dispatch_database(); + session(['company' => 'company-1']); + NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + notification_registry_company_settings('company-1', 'notificationRegistryDispatchNotification__dispatchNotice'); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-session'; + + NotificationRegistry::notify(NotificationRegistryDispatchNotification::class, $subject, 'session'); + + expect(array_column(NotificationRegistryDispatchTarget::$sent, 'target'))->toBe(['target-1']); +}); + +test('notification registry notifies nobody when no company can be resolved', function () { + notification_registry_dispatch_database(); + NotificationRegistry::register(NotificationRegistryDispatchNotification::class); + notification_registry_company_settings('company-1', 'notificationRegistryDispatchNotification__dispatchNotice'); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-orphan'; + + NotificationRegistry::notify(NotificationRegistryDispatchNotification::class, $subject, 'orphan'); + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Dispatch Notice', $subject, 'orphan'); + + expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); +}); + +test('notification registry no longer reads the global notification settings key', function () { + notification_registry_dispatch_database(); + Fleetbase\Models\Setting::query()->create([ + 'key' => 'notification_settings', + 'value' => [ + 'notificationRegistryDispatchNotification__manualNotice' => [ + 'notifiables' => [ + [ + 'definition' => NotificationRegistryDispatchTarget::class, + 'primaryKey' => 'uuid', + 'key' => 'target-1', + ], + ], + ], + ], + ]); + + $subject = new NotificationRegistryDispatchSubject(); + $subject->uuid = 'subject-global'; + $subject->company_uuid = 'company-1'; + + NotificationRegistry::notifyUsingDefinitionName(NotificationRegistryDispatchNotification::class, 'Manual Notice', $subject, 'global'); + + expect(NotificationRegistryDispatchTarget::$sent)->toBe([]); +}); diff --git a/tests/Unit/Support/PushNotificationTest.php b/tests/Unit/Support/PushNotificationTest.php index dd8cc6d2..1de7e857 100644 --- a/tests/Unit/Support/PushNotificationTest.php +++ b/tests/Unit/Support/PushNotificationTest.php @@ -350,6 +350,7 @@ protected static function getFcmMessagingClient(): Messaging 'screen' => 'orders.show', ], 'android' => [ + 'priority' => 'high', 'notification' => [ 'color' => '#4391EA', 'sound' => 'default', diff --git a/tests/Unit/Support/TwoFactorAuthTest.php b/tests/Unit/Support/TwoFactorAuthTest.php index 7f025861..cb64d7a7 100644 --- a/tests/Unit/Support/TwoFactorAuthTest.php +++ b/tests/Unit/Support/TwoFactorAuthTest.php @@ -44,14 +44,33 @@ public function exists(string $key): bool return array_key_exists($key, $this->values); } - public function del(?string $key): bool + public function get(string $key): mixed { - $this->deleted[] = $key; - unset($this->values[$key]); + return $this->values[$key] ?? null; + } + + public function del(?string ...$keys): bool + { + foreach ($keys as $key) { + $this->deleted[] = $key; + unset($this->values[$key]); + } return true; } + public function incr(string $key): int + { + $this->values[$key] = (int) ($this->values[$key] ?? 0) + 1; + + return $this->values[$key]; + } + + public function expire(string $key, int $seconds): bool + { + return array_key_exists($key, $this->values); + } + public function connection(): self { return $this; @@ -384,6 +403,7 @@ function two_factor_auth_verification_code(User $user, Carbon $expiresAt): Verif ->and($redis->sets)->toHaveCount(1) ->and($redis->sets[0]['key'])->toStartWith('two_fa_session:' . $user->uuid . ':') ->and($redis->sets[0]['value'])->toBe($user->uuid) + ->and($redis->sets[0]['options'])->toBe(['EX', TwoFactorAuth::SESSION_TTL]) ->and(TwoFactorAuth::validateSessionToken($token, $user->email))->toBeTrue() ->and(TwoFactorAuth::validateSessionToken($token, '+19999999999'))->toBeFalse() ->and(TwoFactorAuth::createTwoFaSessionIfEnabled('missing@example.com'))->toBeNull(); @@ -583,7 +603,53 @@ function two_factor_auth_verification_code(User $user, Carbon $expiresAt): Verif expect($accessToken)->toContain('|') ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(1) - ->and($redis->deleted)->toBe([$redis->sets[0]['key']]); + ->and($redis->deleted)->toBe([$redis->sets[0]['key'], $redis->sets[0]['key'] . ':attempts']); +}); + +test('two factor auth invalidates the session after too many failed codes', function () { + [$user, , $redis] = two_factor_auth_fixtures(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + + $token = TwoFactorAuth::start($user->email, 10); + $sessionKey = $redis->sets[0]['key']; + $verificationCode = two_factor_auth_verification_code($user, Carbon::now()->addMinutes(5)); + $clientToken = TwoFactorAuth::createClientSessionToken($verificationCode); + + for ($i = 1; $i < TwoFactorAuth::MAX_VERIFY_ATTEMPTS; $i++) { + expect(fn () => TwoFactorAuth::verifyCode('000000', $token, $clientToken)) + ->toThrow(Exception::class, 'Verification code does not match.'); + } + + expect($redis->values[$sessionKey . ':attempts'])->toBe(TwoFactorAuth::MAX_VERIFY_ATTEMPTS - 1) + ->and(fn () => TwoFactorAuth::verifyCode('000000', $token, $clientToken)) + ->toThrow(Exception::class, 'Too many failed verification attempts. Please sign in again.') + ->and($redis->exists($sessionKey))->toBeFalse() + ->and($redis->exists($sessionKey . ':attempts'))->toBeFalse() + // The correct code no longer works once the session is gone. + ->and(fn () => TwoFactorAuth::verifyCode('123456', $token, $clientToken)) + ->toThrow(Exception::class, 'Verification code is invalid.') + ->and(app('db')->table('personal_access_tokens')->count())->toBe(0); +}); + +test('two factor auth keeps counting failed codes across resent codes', function () { + [$user, , $redis] = two_factor_auth_fixtures(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + + $token = TwoFactorAuth::start($user->email, 10); + $sessionKey = $redis->sets[0]['key']; + + for ($i = 1; $i < TwoFactorAuth::MAX_VERIFY_ATTEMPTS; $i++) { + $clientToken = TwoFactorAuth::resendCode($user->email, $token); + + expect(fn () => TwoFactorAuth::verifyCode('not-the-code', $token, $clientToken)) + ->toThrow(Exception::class, 'Verification code does not match.'); + } + + $clientToken = TwoFactorAuth::resendCode($user->email, $token); + + expect(fn () => TwoFactorAuth::verifyCode('not-the-code', $token, $clientToken)) + ->toThrow(Exception::class, 'Too many failed verification attempts. Please sign in again.') + ->and($redis->exists($sessionKey))->toBeFalse(); }); test('two factor auth verify code rejects invalid and mismatched codes', function () { @@ -615,3 +681,204 @@ function two_factor_auth_verification_code(User $user, Carbon $expiresAt): Verif ->and(fn () => TwoFactorAuth::resendCode('missing@example.com', $token))->toThrow(Exception::class, 'No user found using the provided identity') ->and(fn () => TwoFactorAuth::resendCode($user->email, 'invalid-token'))->toThrow(Exception::class, '2FA session is invalid.'); }); + +/** + * Enroll the user in an authenticator app and return its secret and recovery codes. + * + * @return array{0: string, 1: array} + */ +function two_factor_auth_enroll_authenticator(User $user): array +{ + $enrollment = TwoFactorAuth::beginAuthenticatorEnrollment($user); + $recoveryCodes = TwoFactorAuth::confirmAuthenticatorEnrollment($user, (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($enrollment['secret'])); + + return [$enrollment['secret'], $recoveryCodes]; +} + +function two_factor_auth_authenticator_record(User $user): array +{ + return json_decode(app('db')->table('settings')->where('key', 'user.' . $user->uuid . '.2fa_authenticator')->value('value') ?? '[]', true) ?? []; +} + +test('two factor auth sets up an authenticator app only once confirmed with a code from it', function () { + [$user] = two_factor_auth_fixtures(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + + $enrollment = TwoFactorAuth::beginAuthenticatorEnrollment($user); + $pending = two_factor_auth_authenticator_record($user); + + expect($enrollment['secret'])->toMatch('/^[A-Z2-7]{32}$/') + ->and($enrollment['otpauth_url'])->toStartWith('otpauth://totp/Fleetbase:user%40example.com?secret=' . $enrollment['secret']) + ->and($enrollment['qr_code'])->toStartWith('data:image/svg+xml;base64,') + ->and($pending['pending_secret'])->toStartWith('encrypted:') + ->and(json_encode($pending))->not->toContain($enrollment['secret']) + ->and(TwoFactorAuth::hasAuthenticatorApp($user))->toBeFalse() + ->and(fn () => TwoFactorAuth::confirmAuthenticatorEnrollment($user, '000000'))->toThrow(Exception::class, 'The code from your authenticator app is not correct.'); + + $recoveryCodes = TwoFactorAuth::confirmAuthenticatorEnrollment($user, (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($enrollment['secret'])); + $record = two_factor_auth_authenticator_record($user); + + expect($recoveryCodes)->toHaveCount(TwoFactorAuth::RECOVERY_CODE_COUNT) + ->and($recoveryCodes[0])->toMatch('/^[a-z2-9]{5}-[a-z2-9]{5}$/') + ->and($record)->not->toHaveKey('pending_secret') + ->and($record['secret'])->toStartWith('encrypted:') + ->and(json_encode($record))->not->toContain($recoveryCodes[0]) + ->and(TwoFactorAuth::usesAuthenticatorApp($user))->toBeTrue() + ->and(TwoFactorAuth::getTwoFaSettingsForUser($user)->value)->toBe(['enabled' => true, 'method' => 'authenticator_app']) + ->and(TwoFactorAuth::getAuthenticatorStatus($user))->toMatchArray(['enabled' => true, 'recovery_codes_remaining' => 8]) + ->and(array_column(TestActivityLogger::$logged, 'event'))->toBe(['authenticator_enabled']); +}); + +test('two factor auth rejects an authenticator setup that has expired', function () { + [$user] = two_factor_auth_fixtures(); + + $enrollment = TwoFactorAuth::beginAuthenticatorEnrollment($user); + Carbon::setTestNow(Carbon::now()->addSeconds(TwoFactorAuth::AUTHENTICATOR_ENROLLMENT_TTL + 1)); + + expect(fn () => TwoFactorAuth::confirmAuthenticatorEnrollment($user, (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($enrollment['secret']))) + ->toThrow(Exception::class, 'Authenticator setup has expired. Start again.') + ->and(TwoFactorAuth::hasAuthenticatorApp($user))->toBeFalse(); +}); + +test('two factor auth signs in authenticator app users with a code from the app and sends nothing', function () { + [$user, , $redis] = two_factor_auth_fixtures(); + [$secret] = two_factor_auth_enroll_authenticator($user); + TestActivityLogger::$logged = []; + + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email); + + expect(app('db')->table('verification_codes')->count())->toBe(0) + ->and(app('mail.manager')->sent)->toBe([]) + ->and(base64_decode($clientToken))->not->toContain($user->uuid) + ->and(TwoFactorAuth::getChallengeMethod($user->email, $clientToken))->toBe('authenticator_app') + ->and(TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email, $clientToken))->toBe($clientToken) + ->and(TwoFactorAuth::validateSessionToken($token, $user->email, $clientToken))->toBeTrue(); + + // Enrolling used the current time step, so sign in with the next one, which the + // clock drift window still accepts + $google2fa = new PragmaRX\Google2FA\Google2FA(); + $nextCode = $google2fa->oathTotp($secret, two_factor_auth_authenticator_record($user)['last_timestep'] + 1); + $accessToken = TwoFactorAuth::verifyCode($nextCode, $token, $clientToken); + + expect($accessToken)->toContain('|') + ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(1) + ->and(array_keys($redis->values))->toBe([]) + ->and(TestActivityLogger::$logged)->toBe([ + ['log' => 'auth', 'description' => 'Two-factor sign-in verified', 'causer' => $user->uuid, 'subject' => $user->uuid, 'properties' => ['method' => 'authenticator_app'], 'event' => 'two_factor_verified'], + ]); +}); + +test('two factor auth does not accept the same authenticator code twice', function () { + [$user] = two_factor_auth_fixtures(); + [$secret] = two_factor_auth_enroll_authenticator($user); + + // The enrollment code is the current one, so it cannot be used again to sign in + $code = (new PragmaRX\Google2FA\Google2FA())->getCurrentOtp($secret); + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email); + + expect(fn () => TwoFactorAuth::verifyCode($code, $token, $clientToken))->toThrow(Exception::class, 'Verification code does not match.') + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $code))->toBeNull(); +}); + +test('two factor auth accepts each recovery code once', function () { + [$user, , $redis] = two_factor_auth_fixtures(); + [, $recoveryCodes] = two_factor_auth_enroll_authenticator($user); + TestActivityLogger::$logged = []; + + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email); + $accessToken = TwoFactorAuth::verifyCode(strtoupper(str_replace('-', ' ', $recoveryCodes[0])), $token, $clientToken); + + expect($accessToken)->toContain('|') + ->and(TwoFactorAuth::getAuthenticatorStatus($user)['recovery_codes_remaining'])->toBe(7) + ->and(array_column(TestActivityLogger::$logged, 'event'))->toBe(['recovery_code_used', 'two_factor_verified']) + ->and(TestActivityLogger::$logged[1]['properties'])->toBe(['method' => 'recovery_code']) + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $recoveryCodes[0]))->toBeNull() + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $recoveryCodes[1]))->toBe('recovery_code'); +}); + +test('two factor auth counts wrong authenticator codes towards the lockout', function () { + [$user, , $redis] = two_factor_auth_fixtures(); + two_factor_auth_enroll_authenticator($user); + + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email); + + for ($i = 1; $i < TwoFactorAuth::MAX_VERIFY_ATTEMPTS; $i++) { + expect(fn () => TwoFactorAuth::verifyCode('abcde-fghjk', $token, $clientToken))->toThrow(Exception::class, 'Verification code does not match.'); + } + + expect(fn () => TwoFactorAuth::verifyCode('000000', $token, $clientToken))->toThrow(Exception::class, 'Too many failed verification attempts. Please sign in again.') + ->and(TwoFactorAuth::validateSessionToken($token, $user->email, $clientToken))->toBeFalse() + ->and(array_column(TestActivityLogger::$logged, 'event'))->toContain('two_factor_locked'); +}); + +test('two factor auth rejects an authenticator challenge for another user', function () { + [$user] = two_factor_auth_fixtures(); + two_factor_auth_enroll_authenticator($user); + + $other = new User(['uuid' => '44444444-4444-4444-8444-444444444444', 'email' => 'other@example.com', 'name' => 'Other']); + app('db')->table('users')->insert(['uuid' => $other->uuid, 'email' => $other->email, 'name' => $other->name, 'created_at' => now(), 'updated_at' => now()]); + TwoFactorAuth::saveTwoFaSettingsForUser($other, ['enabled' => true, 'method' => 'email']); + + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession(TwoFactorAuth::start($user), $user->email); + $otherToken = TwoFactorAuth::start($other); + + expect(TwoFactorAuth::validateSessionToken($otherToken, $other->email, $clientToken))->toBeFalse() + ->and(fn () => TwoFactorAuth::getClientSessionTokenFromTwoFaSession($otherToken, $other->email, $clientToken))->toThrow(Exception::class, '2FA Verification session has expired.') + ->and(fn () => TwoFactorAuth::verifyCode('123456', $otherToken, $clientToken))->toThrow(Exception::class, 'Verification code is invalid.'); +}); + +test('two factor auth sends authenticator app users a code by email as a fallback', function () { + [$user] = two_factor_auth_fixtures(); + two_factor_auth_enroll_authenticator($user); + + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::resendCode($user->email, $token); + + expect(app('db')->table('verification_codes')->count())->toBe(1) + ->and(app('mail.manager')->sent)->toHaveCount(1) + ->and(TwoFactorAuth::getChallengeMethod($user->email, $clientToken))->toBe('email') + ->and(TwoFactorAuth::verifyCode(app('db')->table('verification_codes')->value('code'), $token, $clientToken))->toContain('|'); +}); + +test('two factor auth turns two factor off when the authenticator app it used is removed', function () { + [$user] = two_factor_auth_fixtures(); + [, $recoveryCodes] = two_factor_auth_enroll_authenticator($user); + + TwoFactorAuth::disableAuthenticatorApp($user); + + expect(TwoFactorAuth::hasAuthenticatorApp($user))->toBeFalse() + ->and(two_factor_auth_authenticator_record($user))->toBe([]) + ->and(TwoFactorAuth::getTwoFaSettingsForUser($user)->value)->toBe(['enabled' => false, 'method' => 'email']) + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $recoveryCodes[0]))->toBeNull() + ->and(array_column(TestActivityLogger::$logged, 'event'))->toBe(['authenticator_enabled', 'authenticator_disabled']); +}); + +test('two factor auth replaces recovery codes', function () { + [$user] = two_factor_auth_fixtures(); + [, $oldCodes] = two_factor_auth_enroll_authenticator($user); + + $newCodes = TwoFactorAuth::regenerateRecoveryCodes($user); + + expect($newCodes)->toHaveCount(8) + ->and(array_intersect($oldCodes, $newCodes))->toBe([]) + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $oldCodes[0]))->toBeNull() + ->and(TwoFactorAuth::verifyAuthenticatorCode($user, $newCodes[0]))->toBe('recovery_code'); + + TwoFactorAuth::disableAuthenticatorApp($user); + + expect(fn () => TwoFactorAuth::regenerateRecoveryCodes($user))->toThrow(Exception::class, 'Set up an authenticator app first.'); +}); + +test('two factor auth rejects a pending authenticator challenge after its user is removed', function () { + [$user] = two_factor_auth_fixtures(); + two_factor_auth_enroll_authenticator($user); + $token = TwoFactorAuth::start($user); + $clientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $user->email); + app('db')->table('users')->where('uuid', $user->uuid)->delete(); + + expect(fn () => TwoFactorAuth::verifyCode('123456', $token, $clientToken))->toThrow(Exception::class, 'Verification code is invalid.'); +});