diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 2aba7e97d..b5bb41357 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -100,6 +100,28 @@ 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'), ], + /* + |-------------------------------------------------------------------------- + | Customers + |-------------------------------------------------------------------------- + | + | Testing-only verification-code bypass for the customer auth flows + | (POST /v1/customers, /customers/verify-code, /customers/reset-password). + | Intended for local development and staging QA, where signing up means + | waiting on a real email or paying for a real SMS. + | + | MUST be left unset in production. It is ignored outright when the app + | environment is `production`, and when unset no bypass is possible. + | + | Deliberately NOT wired to SMS_AUTH_BYPASS_CODE: that variable already + | gates operator console login and driver login, and reusing it here would + | make one leaked value unlock three different privilege tiers. + | + */ + 'customers' => [ + 'verification_bypass_code' => env('FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE'), + ], + /* |-------------------------------------------------------------------------- | API Events diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php index 149554d5d..7b54739dd 100644 --- a/server/src/Http/Controllers/Api/v1/CustomerController.php +++ b/server/src/Http/Controllers/Api/v1/CustomerController.php @@ -144,7 +144,7 @@ public function create(CreateCustomerRequest $request) 'for' => 'fleetops_create_customer', 'meta->identity' => $identity, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid verification code provided.'); } @@ -417,7 +417,7 @@ public function verifyCode(Request $request) 'code' => $code, 'for' => $for, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid verification code.'); } @@ -503,7 +503,7 @@ public function resetPassword(Request $request) 'for' => 'fleetops_customer_password_reset', 'meta->identity' => $needle, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($code)) { return response()->apiError('Invalid reset code.'); } @@ -517,7 +517,10 @@ public function resetPassword(Request $request) $user->save(); // Invalidate all existing sessions for this user after a password reset. $this->deleteUserTokens($user); - $verificationCode->delete(); + // Null on the testing-bypass path — there is no row to consume. + if ($verificationCode) { + $verificationCode->delete(); + } return response()->json(['status' => 'ok']); } @@ -918,6 +921,35 @@ protected function generateSmsVerification(User $user, string $for, array $optio return VerificationCode::generateSmsVerificationFor($user, $for, $options); } + /** + * Whether the supplied code matches the configured testing bypass code. + * + * Three conditions, all required, mirroring the console equivalent in + * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode: + * a bypass code must actually be configured, the app must not be running in + * production, and the comparison is constant-time. + * + * Fails safe by default: config/app.php resolves `env` to `production` when + * neither APP_ENV nor ENVIRONMENT is set, so an unconfigured install cannot + * be bypassed even accidentally. + * + * `!== null && !== ''` rather than `!empty()` — `!empty('0')` is false, so a + * configured bypass code of "0" would otherwise be silently ignored. + * + * Kept out of verificationCodeExists()/findVerificationCode() on purpose: + * those two are test seams that the controller contract tests override, so a + * policy living inside them would be stubbed away exactly where it matters. + */ + protected function verificationBypassMatches(?string $code): bool + { + $bypassCode = config('fleetops.customers.verification_bypass_code'); + + return $bypassCode !== null + && $bypassCode !== '' + && !app()->environment('production') + && hash_equals((string) $bypassCode, (string) $code); + } + protected function verificationCodeExists(array $attributes): bool { return VerificationCode::where($attributes)->exists(); diff --git a/server/src/Http/Requests/CreateCustomerRequest.php b/server/src/Http/Requests/CreateCustomerRequest.php index bd4265d80..761606858 100644 --- a/server/src/Http/Requests/CreateCustomerRequest.php +++ b/server/src/Http/Requests/CreateCustomerRequest.php @@ -23,7 +23,15 @@ public function rules(): array { return [ 'identity' => 'required|string', - 'code' => 'required|exists:verification_codes,code', + // Presence only — authorizing the code is the controller's job, and its + // check is strictly stronger: it matches code + for + meta->identity, + // whereas `exists:verification_codes,code` accepts any live code issued + // for any purpose to any user. The rule is also already unenforced on the + // proxy path, since verifyCode() with for=fleetops_create_customer calls + // create(CreateCustomerRequest::createFrom($request)), which never runs + // validateResolved(). Keeping it here only blocks the configured + // non-production testing bypass (fleetops.customers.verification_bypass_code). + 'code' => 'required|string', 'name' => 'required|string', 'password' => 'required|string|min:8', 'email' => [ diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php index e0c9da3e2..f25e60959 100644 --- a/server/tests/ApiCustomerControllerContractsTest.php +++ b/server/tests/ApiCustomerControllerContractsTest.php @@ -960,3 +960,158 @@ function fleetopsApiCustomerJson($response): array 'name' => 'explode', ]))))->toBe(['error' => 'update failed']); }); + +/** + * Run a callback with an app container that supports environment(). + * + * The harness binds a bare Illuminate\Container\Container, which has no + * environment() — so CustomerController::verificationBypassMatches fatals with + * "Call to undefined method" without this. Mirrors the swap in + * NotificationAndMailContractsTest, but carries every existing binding across so + * the controller can still resolve config/request/db. + */ +function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed +{ + $previousApp = Illuminate\Container\Container::getInstance(); + $app = new class extends Illuminate\Container\Container { + public string $fleetopsEnvironment = 'testing'; + + public function environment(...$environments) + { + if (empty($environments)) { + return $this->fleetopsEnvironment; + } + + $environments = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array($this->fleetopsEnvironment, $environments, true); + } + + public function hasDebugModeEnabled() + { + return false; + } + }; + $app->fleetopsEnvironment = $environment; + + $reflection = new ReflectionClass(Illuminate\Container\Container::class); + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) { + if (!$reflection->hasProperty($property)) { + continue; + } + $handle = $reflection->getProperty($property); + $handle->setAccessible(true); + $handle->setValue($app, $handle->getValue($previousApp)); + } + + Illuminate\Container\Container::setInstance($app); + + try { + return $callback(); + } finally { + Illuminate\Container\Container::setInstance($previousApp); + } +} + +test('api customer controller ignores the verification bypass unless it is configured', function () { + // Unset and empty-string are both inert. Without this the bypass would be a + // standing hole in every default install, which is the entire risk of shipping one. + foreach ([null, ''] as $bypassCode) { + config(['fleetops.customers.verification_bypass_code' => $bypassCode]); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + } + + config(['fleetops.customers.verification_bypass_code' => null]); +}); + +test('api customer controller accepts a configured verification bypass outside production', function () { + config(['fleetops.customers.verification_bypass_code' => '000000']); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + // No VerificationCode row on the bypass path — resetPassword must not fatal + // calling ->delete() on null, and must still revoke existing sessions. + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + // A non-matching code is still rejected while the bypass is live. + $wrong = fleetopsApiCustomerController(); + $wrong->verificationExists = false; + + expect($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + 'name' => 'Jane', + 'password' => 'password-secret', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['status' => 'ok']) + ->and($reset->genericUser->tokensDeleted)->toBeTrue() + ->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([ + 'code' => '999999', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']); + }); + + config(['fleetops.customers.verification_bypass_code' => null]); +}); + +test('api customer controller refuses the verification bypass in production', function () { + config(['fleetops.customers.verification_bypass_code' => '000000']); + + fleetopsApiCustomerWithEnvironment('production', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + + config(['fleetops.customers.verification_bypass_code' => null]); +}); diff --git a/server/tests/CustomerEndpointTest.php b/server/tests/CustomerEndpointTest.php index ce9610526..38670211b 100644 --- a/server/tests/CustomerEndpointTest.php +++ b/server/tests/CustomerEndpointTest.php @@ -231,8 +231,11 @@ function fleetopsCustomerEndpointJson(JsonResponse $response): array $create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php'); $verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php'); + // These match against source text, so they have to be updated whenever the rule + // strings change. `code` is presence-only by design — the controller's own check + // (code + for + meta->identity) is strictly stronger than exists:verification_codes. expect($create) - ->toContain("'code' => 'required|exists:verification_codes,code'") + ->toContain("'code' => 'required|string'") ->toContain("'password' => 'required|string|min:8'") ->and($verify)->toContain("'mode' => 'required|in:email,sms'"); }); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index e0fcf72d2..d6e1051fb 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -621,7 +621,7 @@ protected function canUpdateDriver(): bool expect($request->authorize())->toBeTrue() ->and($rules['identity'])->toBe('required|string') - ->and($rules['code'])->toBe('required|exists:verification_codes,code') + ->and($rules['code'])->toBe('required|string') ->and($rules['name'])->toBe('required|string') ->and($rules['password'])->toBe('required|string|min:8') ->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts')