diff --git a/composer.json b/composer.json index 52a0d7f3..3a31ff07 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.35", + "version": "1.6.36", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/config/fleetbase.php b/config/fleetbase.php index b9027c95..aae5708c 100644 --- a/config/fleetbase.php +++ b/config/fleetbase.php @@ -35,6 +35,22 @@ ], 'version' => env('FLEETBASE_VERSION', '0.7.1'), 'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null), + + /* + |-------------------------------------------------------------------------- + | SMS Authentication Bypass Code + |-------------------------------------------------------------------------- + | + | This value allows a configurable bypass code for SMS-based authentication, + | intended strictly for testing and development environments. It MUST be + | left null (unset) in production. When null or empty, no bypass is + | permitted and only the genuine Redis-stored OTP will be accepted. + | + | Environment variable: SMS_AUTH_BYPASS_CODE + | + */ + 'sms_auth_bypass_code' => env('SMS_AUTH_BYPASS_CODE'), + 'user_cache' => [ 'enabled' => env('USER_CACHE_ENABLED', true), 'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 0e837a85..70b4953e 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -46,14 +46,31 @@ public function login(LoginRequest $request) $password = $request->input('password'); $authToken = $request->input('authToken'); - // if attempting to authenticate with auth token validate it first against database and respond with it + // If an existing auth token is provided, attempt to re-authenticate with it. + // The token must be valid AND must belong to the user identified by the + // 'identity' field in this request, preventing token-swap attacks where a + // token from one user could be used to authenticate as another. if ($authToken) { $personalAccessToken = PersonalAccessToken::findToken($authToken); - $personalAccessToken->loadMissing('tokenable'); if ($personalAccessToken) { - return response()->json(['token' => $authToken, 'type' => $personalAccessToken->tokenable instanceof User ? $personalAccessToken->tokenable->getType() : null]); + $personalAccessToken->loadMissing('tokenable'); + $tokenOwner = $personalAccessToken->tokenable; + + if ( + $tokenOwner instanceof User + && ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) + ) { + return response()->json([ + 'token' => $authToken, + 'type' => $tokenOwner->getType(), + ]); + } } + + // If the token is invalid or does not match the claimed identity, fall + // through silently to normal password-based authentication. Do not + // return an error here to avoid leaking whether the token exists. } // Find the user using the identity provided @@ -61,8 +78,19 @@ public function login(LoginRequest $request) $query->where('email', $identity)->orWhere('phone', $identity); })->first(); - if (!$user) { - return response()->error('No user found by the provided identity.', 401, ['code' => 'no_user']); + // If the user exists but has no password set (e.g. SSO-invited or provisioned + // accounts), silently fall through to the generic credentials error below. + // This guard MUST come before isInvalidPassword() which has a strict string + // type declaration on $hashedPassword and would throw a TypeError on null. + // We do NOT return a distinct error here to avoid leaking account state. + if ($user && empty($user->password)) { + $user = null; + } + + // Use a generic error message for both non-existent user and wrong password + // to prevent user enumeration via differential error responses. + if (!$user || Auth::isInvalidPassword($password, $user->password)) { + return response()->error('These credentials do not match our records.', 401, ['code' => 'invalid_credentials']); } // Check if 2FA enabled @@ -75,15 +103,6 @@ public function login(LoginRequest $request) ]); } - // If no password prompt user to reset password - if (empty($user->password)) { - return response()->error('Password reset required to continue.', 400, ['code' => 'reset_password']); - } - - if (Auth::isInvalidPassword($password, $user->password)) { - return response()->error('Authentication failed using password provided.', 401, ['code' => 'invalid_password']); - } - if ($user->isNotVerified() && $user->isNotAdmin()) { return response()->error('User is not verified.', 400, ['code' => 'not_verified']); } @@ -274,13 +293,13 @@ public function sendVerificationSms(Request $request) // Send user their verification code try { - Twilio::message($queryPhone, shell_exec('Your Fleetbase authentication code is ') . $verifyCode); + Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode); } catch (\Exception|\Twilio\Exceptions\RestException $e) { return response()->json(['error' => $e->getMessage()], 400); } - // Store verify code for this number - Redis::set($verifyCodeKey, $verifyCode); + // Store verify code for this number with a 10-minute TTL to prevent replay attacks + Redis::setex($verifyCodeKey, 600, $verifyCode); // 200 OK return response()->json(['status' => 'OK']); @@ -308,11 +327,22 @@ public function authenticateSmsCode(Request $request) $verifyCode = $request->input('code'); $verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_'); - // Generate hto + // Retrieve the stored verification code from Redis $storedVerifyCode = Redis::get($verifyCodeKey); - // Verify - if ($verifyCode !== '000999' && $verifyCode !== $storedVerifyCode) { + // Retrieve the optional testing bypass code from configuration. + // This is configurable via the SMS_AUTH_BYPASS_CODE environment variable + // and is intended for development/testing environments only. + // It MUST be left unset (null) in production deployments. + $bypassCode = config('fleetbase.sms_auth_bypass_code'); + + // Verify the submitted code against the stored OTP using a constant-time + // comparison to prevent timing attacks. If a bypass code is configured + // and the environment is not production, also allow that code. + $isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode); + $isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode); + + if (!$isValidOtp && !$isBypassValid) { return response()->error('Invalid verification code'); } diff --git a/src/Http/Controllers/Internal/v1/InstallerController.php b/src/Http/Controllers/Internal/v1/InstallerController.php index 11dd341b..b7d1b7ab 100644 --- a/src/Http/Controllers/Internal/v1/InstallerController.php +++ b/src/Http/Controllers/Internal/v1/InstallerController.php @@ -103,7 +103,7 @@ public function migrate() ini_set('memory_limit', '-1'); ini_set('max_execution_time', 0); - shell_exec(base_path('artisan') . ' migrate'); + Artisan::call('migrate', ['--force' => true]); Artisan::call('sandbox:migrate'); // Clear cache after migration diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 02e1677a..ca060c1d 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -14,6 +14,7 @@ use Fleetbase\Http\Requests\Internal\ResendUserInvite; use Fleetbase\Http\Requests\Internal\UpdatePasswordRequest; use Fleetbase\Http\Requests\Internal\ValidatePasswordRequest; +use Fleetbase\Http\Requests\UpdateUserRequest; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; @@ -57,6 +58,16 @@ class UserController extends FleetbaseController */ public $createRequest = CreateUserRequest::class; + /** + * Update user request. + * + * Enforces that email and phone cannot be set to an empty string + * and that uniqueness constraints are respected on update. + * + * @var UpdateUserRequest + */ + public $updateRequest = UpdateUserRequest::class; + /** * Creates a record with request payload. * @@ -126,6 +137,11 @@ public function createRecord(Request $request) */ public function updateRecord(Request $request, string $id) { + // Run the UpdateUserRequest validation rules before delegating to the + // model trait. This prevents email/phone being set to an empty string + // and enforces uniqueness constraints on partial (PATCH) updates. + $this->validateRequest($request); + try { $record = $this->model->updateRecordFromRequest($request, $id, function (&$request, &$user) { // Assign role if set @@ -182,7 +198,7 @@ public function current(Request $request) // Try to get from server cache $companyId = session('company'); - $cachedData = UserCacheService::get($user->id, $companyId); + $cachedData = UserCacheService::get($user, $companyId); if ($cachedData) { // Return cached data with cache headers @@ -202,7 +218,7 @@ public function current(Request $request) $userArray = $userData->toArray($request); // Store in cache - UserCacheService::put($user->id, $companyId, $userArray); + UserCacheService::put($user, $companyId, $userArray); // Return with cache headers return response()->json(['user' => $userArray]) @@ -404,18 +420,56 @@ public function deactivate($id) return response()->error('No user to deactivate', 401); } - $user = User::where('uuid', $id)->first(); + $currentUser = request()->user(); + + // Scope the lookup to the current company to prevent cross-organization IDOR. + $user = User::where('uuid', $id) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); if (!$user) { - return response()->error('No user found', 401); + return response()->error('No user found', 404); } - $user->deactivate(); - $user = $user->refresh(); + // Prevent a user from deactivating their own account via this endpoint. + if ($currentUser && $currentUser->uuid === $user->uuid) { + return response()->error('You cannot deactivate your own account.', 403); + } + + // Layered privilege check: + // + // Tier 1 — System admins (isAdmin()) can deactivate anyone except other + // system admins. This is the highest privilege tier. + if ($user->isAdmin()) { + return response()->error('Insufficient permissions to deactivate this user.', 403); + } + + // Tier 2 — Users holding the 'Administrator' role can only be deactivated + // by a system admin (handled above). A regular user or another + // role-based Administrator cannot deactivate them. + if ($user->hasRole('Administrator') && $currentUser && !$currentUser->isAdmin()) { + return response()->error('Insufficient permissions to deactivate this user.', 403); + } + + // Only deactivate the CompanyUser record for the current organisation. + // Calling User::deactivate() would set the user's global status to + // 'inactive', locking them out of every organisation they belong to. + // Instead we update only the pivot record so the user remains active + // in any other organisations they are a member of. + $companyUser = $user->companyUsers()->where('company_uuid', session('company'))->first(); + + if (!$companyUser) { + return response()->error('User is not a member of this organisation.', 404); + } + + $companyUser->status = 'inactive'; + $companyUser->save(); return response()->json([ 'message' => 'User deactivated', - 'status' => $user->session_status, + 'status' => $companyUser->status, ]); } @@ -430,12 +484,24 @@ public function activate($id) return response()->error('No user to activate', 401); } - $user = User::where('uuid', $id)->first(); + $currentUser = request()->user(); + + // Scope the lookup to the current company to prevent cross-organisation IDOR. + $user = User::where('uuid', $id) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); if (!$user) { - return response()->error('No user found', 401); + return response()->error('No user found', 404); } + // Activate both the User record and the CompanyUser record. + // Unlike deactivation (which is scoped to the current organisation only), + // activation must also update users.status because a newly created user + // starts with a global status of 'inactive' and needs to be unblocked + // at the user level before they can access any organisation. $user->activate(); $user = $user->refresh(); diff --git a/src/Http/Filter/Filter.php b/src/Http/Filter/Filter.php index af36f4ec..8398bc7e 100644 --- a/src/Http/Filter/Filter.php +++ b/src/Http/Filter/Filter.php @@ -145,9 +145,18 @@ private function applyFilter($name, $value) } } - // Check if it's an expansion (only if method not found) - if (!$this->methodCache[$cacheKey] && static::isExpansion($name)) { - $this->methodCache[$cacheKey] = $name; + // Check if it's an expansion (only if method not found). + // Expansions are registered under their PHP method name (camelCase), so we must + // check both the raw param name (e.g. 'doesnt_have_driver') and its camelCase + // equivalent (e.g. 'doesntHaveDriver') to correctly resolve snake_case query + // params that map to camelCase expansion methods. + if (!$this->methodCache[$cacheKey]) { + $camelName = Str::camel($name); + if (static::isExpansion($name)) { + $this->methodCache[$cacheKey] = $name; + } elseif (static::isExpansion($camelName)) { + $this->methodCache[$cacheKey] = $camelName; + } } } diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 390ae34f..40fcedb6 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -29,6 +29,11 @@ public function isNotAdmin() $this->builder->where('type', '!=', 'admin'); } + public function isUser() + { + $this->builder->whereIn('type', ['user', 'admin']); + } + public function query(?string $query) { $this->builder->search($query); @@ -51,8 +56,11 @@ public function email(?string $email) public function role(?string $roleId) { - $this->builder->whereHas('roles', function ($query) use ($roleId) { - $query->where('id', $roleId); + $this->builder->whereHas('companyUsers', function ($query) use ($roleId) { + $query->where('company_uuid', session('company')); + $query->whereHas('roles', function ($query) use ($roleId) { + $query->where('id', $roleId); + }); }); } } diff --git a/src/Http/Requests/Internal/ResetPasswordRequest.php b/src/Http/Requests/Internal/ResetPasswordRequest.php index d761fe5c..09d20bbc 100644 --- a/src/Http/Requests/Internal/ResetPasswordRequest.php +++ b/src/Http/Requests/Internal/ResetPasswordRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests\Internal; use Fleetbase\Http\Requests\FleetbaseRequest; +use Illuminate\Validation\Rules\Password; class ResetPasswordRequest extends FleetbaseRequest { @@ -26,8 +27,18 @@ public function rules() return [ 'code' => ['required', 'exists:verification_codes,code'], 'link' => ['required', 'exists:verification_codes,uuid'], - 'password' => ['required', 'confirmed', 'min:4', 'max:24'], - 'password_confirmation' => ['required', 'min:4', 'max:24'], + 'password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -39,8 +50,15 @@ public function rules() public function messages() { return [ - 'code' => 'Invalid password reset request!', - 'link' => 'Invalid password reset request!', + 'code' => 'Invalid password reset request!', + 'link' => 'Invalid password reset request!', + 'password.required' => 'You must enter a 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/Requests/Internal/UpdatePasswordRequest.php b/src/Http/Requests/Internal/UpdatePasswordRequest.php index c90ce137..62da58ff 100644 --- a/src/Http/Requests/Internal/UpdatePasswordRequest.php +++ b/src/Http/Requests/Internal/UpdatePasswordRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests\Internal; use Fleetbase\Http\Requests\FleetbaseRequest; +use Illuminate\Validation\Rules\Password; class UpdatePasswordRequest extends FleetbaseRequest { @@ -24,8 +25,18 @@ public function authorize() public function rules() { return [ - 'password' => ['required', 'confirmed', 'min:4', 'max:24'], - 'password_confirmation' => ['required'], + 'password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -37,7 +48,13 @@ public function rules() public function messages() { return [ - 'password.required' => 'You must enter a password', + 'password.required' => 'You must enter a 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/Requests/Internal/ValidatePasswordRequest.php b/src/Http/Requests/Internal/ValidatePasswordRequest.php index 5deace22..432001f4 100644 --- a/src/Http/Requests/Internal/ValidatePasswordRequest.php +++ b/src/Http/Requests/Internal/ValidatePasswordRequest.php @@ -4,6 +4,7 @@ use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Validation\Rules\Password; class ConfirmCurrentPassword implements Rule { @@ -57,8 +58,19 @@ public function authorize() public function rules() { return [ - 'password' => ['required', 'string', 'min:4', 'max:24', 'confirmed', new ConfirmCurrentPassword($this->user())], - 'password_confirmation' => ['required', 'string'], + 'password' => [ + 'required', + 'string', + 'confirmed', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + new ConfirmCurrentPassword($this->user()), + ], + 'password_confirmation' => ['required', 'string'], ]; } @@ -70,9 +82,14 @@ public function rules() public function messages() { return [ - 'password.required' => 'The current password is required.', - 'password.string' => 'The current password must be a string.', - 'password.min' => 'The current password must be at least 8 characters.', + 'password.required' => 'The current password is required.', + 'password.string' => 'The current password must be a string.', + '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/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php index 08446a63..b7f5c979 100644 --- a/src/Http/Requests/LoginRequest.php +++ b/src/Http/Requests/LoginRequest.php @@ -40,8 +40,12 @@ protected function failedValidation(Validator $validator) public function rules() { return [ - 'identity' => 'required|email|exists:users,email', - 'password' => 'required', + // Intentionally no 'exists:users,email' rule here — exposing whether + // an identity exists in the database enables user enumeration attacks. + // Validation of identity existence is handled in the controller with + // a generic error message to prevent information leakage. + 'identity' => ['required'], + 'password' => ['required'], ]; } @@ -53,10 +57,8 @@ public function rules() public function messages() { return [ - 'identity.required' => 'A email is required', - 'identity.exists' => 'No user found by this email', - 'identity.email' => 'Email used is invalid', - 'password.required' => 'A password is required', + 'identity.required' => 'An email address or phone number is required.', + 'password.required' => 'A password is required.', ]; } } diff --git a/src/Http/Requests/SignUpRequest.php b/src/Http/Requests/SignUpRequest.php index 654b84cd..a026cf35 100644 --- a/src/Http/Requests/SignUpRequest.php +++ b/src/Http/Requests/SignUpRequest.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Requests; use Illuminate\Contracts\Validation\Validator; +use Illuminate\Validation\Rules\Password; class SignUpRequest extends FleetbaseRequest { @@ -56,7 +57,17 @@ public function rules() return [ 'user.name' => ['required'], 'user.email' => ['required', 'email'], - 'user.password' => ['required', 'confirmed', 'min:4', 'max:24'], + 'user.password' => [ + 'required', + 'confirmed', + 'string', + Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised(), + ], 'user.password_confirmation' => ['required'], 'company.name' => ['required'], ]; @@ -70,10 +81,16 @@ public function rules() public function messages() { return [ - '*.required' => 'Your :attribute is required to signup', - 'user.email' => 'You must enter a valid :attribute to signup', - 'user.email.unique' => 'An account with this email address already exists', - 'user.password.required' => 'You must enter a password to signup', + '*.required' => 'Your :attribute is required to signup', + 'user.email' => 'You must enter a valid :attribute to signup', + 'user.email.unique' => 'An account with this email address already exists', + 'user.password.required' => 'You must enter a password to signup', + 'user.password.min' => 'Password must be at least 8 characters.', + 'user.password.mixed' => 'Password must contain both uppercase and lowercase letters.', + 'user.password.letters' => 'Password must contain at least one letter.', + 'user.password.numbers' => 'Password must contain at least one number.', + 'user.password.symbols' => 'Password must contain at least one symbol.', + 'user.password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.', ]; } } diff --git a/src/Http/Requests/UpdateUserRequest.php b/src/Http/Requests/UpdateUserRequest.php new file mode 100644 index 00000000..e676e2ee --- /dev/null +++ b/src/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,85 @@ +route('id'); + + return [ + 'name' => ['sometimes', 'required', 'string', 'min:2', 'max:100'], + + // Email must be a valid address, must not be empty, and must remain + // unique across non-deleted users — ignoring the current user's own row. + 'email' => [ + 'sometimes', + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users', 'email') + ->ignore($userId, 'uuid') + ->whereNull('deleted_at'), + new EmailDomainExcluded(), + ], + + // Phone is optional (some user types may not have one), but if it is + // supplied it must be a valid E.164 number and must remain unique. + // `nullable` allows explicit null to clear the field; `required_with` + // is not used here because phone is genuinely optional on some accounts. + 'phone' => [ + 'sometimes', + 'nullable', + new ValidPhoneNumber(), + Rule::unique('users', 'phone') + ->ignore($userId, 'uuid') + ->whereNull('deleted_at'), + ], + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'name.required' => 'Name cannot be empty.', + 'name.min' => 'Name must be at least 2 characters.', + 'email.required' => 'Email address cannot be empty.', + 'email.email' => 'A valid email address is required.', + 'email.unique' => 'An account with this email address already exists.', + 'phone.unique' => 'An account with this phone number already exists.', + ]; + } +} diff --git a/src/Mail/VerificationMail.php b/src/Mail/VerificationMail.php index 16a616cb..283d708c 100644 --- a/src/Mail/VerificationMail.php +++ b/src/Mail/VerificationMail.php @@ -5,8 +5,6 @@ use Fleetbase\Models\VerificationCode; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; -use Illuminate\Mail\Mailables\Content; -use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class VerificationMail extends Mailable @@ -17,12 +15,12 @@ class VerificationMail extends Mailable /** * The verification code to email. */ - private VerificationCode $verificationCode; + public VerificationCode $verificationCode; /** * Custom content to render if supplied. */ - private ?string $content; + public ?string $content; /** * Create a new message instance. @@ -36,30 +34,21 @@ public function __construct(VerificationCode $verificationCode, ?string $content } /** - * Get the message content definition. - */ - public function envelope(): Envelope - { - return new Envelope( - subject: $this->verificationCode->code . ' is your ' . config('app.name') . ' verification code', - ); - } - - /** - * Get the message content definition. + * Build the message. + * + * @return $this */ - public function content(): Content + public function build() { - return new Content( - markdown: 'fleetbase::mail.verification', - with: [ + return $this + ->subject($this->verificationCode->code . ' is your ' . config('app.name') . ' verification code') + ->markdown('fleetbase::mail.verification', [ 'appName' => config('app.name'), 'currentHour' => now()->hour, 'user' => $this->verificationCode->subject, 'code' => $this->verificationCode->code, 'type' => $this->verificationCode->for, 'content' => $this->content, - ] - ); + ]); } } diff --git a/src/Models/Model.php b/src/Models/Model.php index c4bdd904..0ee70bf1 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Scopes\CompanyScope; use Fleetbase\Traits\ClearsHttpCache; use Fleetbase\Traits\Expandable; use Fleetbase\Traits\Filterable; @@ -65,6 +66,27 @@ public function __construct(array $attributes = []) */ public $incrementing = false; + /** + * Boot the model and register global scopes. + * + * The CompanyScope is registered here so that every subclass automatically + * inherits tenant isolation on all Eloquent queries. The scope is + * self-guarding: it only activates when a company UUID is present in the + * session and the model's table has a `company_uuid` column, so models + * without that column (User, Company, Setting, etc.) are unaffected. + * + * To bypass the scope for a specific query (e.g. super-admin tooling): + * Model::withoutCompanyScope()->where(...)->get(); + * + * @return void + */ + protected static function boot() + { + parent::boot(); + + static::addGlobalScope(new CompanyScope()); + } + /** * Determines if model is searchable. * diff --git a/src/Models/User.php b/src/Models/User.php index 7cc1ab60..881c7344 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -1343,6 +1343,8 @@ public function assignSingleRole($role): self $this->companyUser->assignSingleRole($role); // Invalidate user cache after role change + // Note: With updated_at in cache key, this provides immediate invalidation + // while the timestamp-based key provides automatic cache busting \Fleetbase\Services\UserCacheService::invalidateUser($this); return $this; diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index 9f616d59..dc804989 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -88,8 +88,9 @@ public function subject() */ public static function generateFor($subject = null, $for = 'general_verification', $save = true) { - $verifyCode = new static(); - $verifyCode->for = $for; + $verifyCode = new static(); + $verifyCode->for = $for; + $verifyCode->status = 'pending'; // Set default status if ($subject) { $verifyCode->setSubject($subject, false); diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index 820c9948..ca726457 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -15,6 +15,8 @@ class UserObserver public function updated(User $user): void { // Invalidate user cache when user is updated + // Note: With updated_at in cache key, this provides immediate invalidation + // while the timestamp-based key provides automatic cache busting UserCacheService::invalidateUser($user); // Invalidate organizations cache (user might be an owner) diff --git a/src/Scopes/CompanyScope.php b/src/Scopes/CompanyScope.php new file mode 100644 index 00000000..f8df4917 --- /dev/null +++ b/src/Scopes/CompanyScope.php @@ -0,0 +1,116 @@ +where(...)->get(); + * Model::withoutGlobalScope(CompanyScope::class)->where(...)->get(); + * + * The `withoutCompanyScope()` macro is the preferred, readable form. + */ +class CompanyScope implements Scope +{ + /** + * Per-process cache of which table names have a `company_uuid` column. + * Avoids repeated Schema::hasColumn() calls on the same table. + * + * @var array + */ + protected static array $columnCache = []; + + /** + * Apply the scope to a given Eloquent query builder. + * + * @return void + */ + public function apply(Builder $builder, Model $model) + { + // Never apply during CLI execution (artisan, queue workers, etc.) + if (app()->runningInConsole()) { + return; + } + + $companyUuid = Session::get('company'); + + // Only apply when there is an active company session. + if (empty($companyUuid)) { + return; + } + + // Only apply when the model's table actually has a company_uuid column. + // Cache the result per table to avoid repeated Schema introspection. + $table = $model->getTable(); + if (!isset(static::$columnCache[$table])) { + static::$columnCache[$table] = Schema::hasColumn($table, 'company_uuid'); + } + + if (!static::$columnCache[$table]) { + return; + } + + $builder->where($model->qualifyColumn('company_uuid'), $companyUuid); + } + + /** + * Extend the query builder with the withoutCompanyScope macro. + * + * @return void + */ + public function extend(Builder $builder) + { + $this->addWithoutCompanyScope($builder); + } + + /** + * Add the withoutCompanyScope macro to the builder. + * + * @return void + */ + protected function addWithoutCompanyScope(Builder $builder) + { + $builder->macro('withoutCompanyScope', function (Builder $builder) { + return $builder->withoutGlobalScope(CompanyScope::class); + }); + } + + /** + * Flush the column existence cache. + * Useful in tests where tables may be created/dropped between cases. + */ + public static function flushColumnCache(): void + { + static::$columnCache = []; + } +} diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index afab319e..1967325c 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -161,11 +161,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): 'response' => $response, ]; } catch (\Throwable $e) { - return [ - 'success' => false, - 'error' => $e->getMessage(), - 'code' => $e->getCode(), - ]; + // return [ + // 'success' => false, + // 'error' => $e->getMessage(), + // 'code' => $e->getCode(), + // ]; + + throw $e; } } diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php index f2d9a56f..8b28b9e8 100644 --- a/src/Services/UserCacheService.php +++ b/src/Services/UserCacheService.php @@ -25,29 +25,26 @@ class UserCacheService /** * Generate cache key for a user and company. - * - * @param int|string $userId + * Includes the user's updated_at timestamp for automatic cache busting. */ - public static function getCacheKey($userId, string $companyId): string + public static function getCacheKey(User $user, string $companyId): string { - return self::CACHE_PREFIX . $userId . ':' . $companyId; + return self::CACHE_PREFIX . $user->uuid . ':' . $companyId . ':' . $user->updated_at->timestamp; } /** * Get cached user data. - * - * @param int|string $userId */ - public static function get($userId, string $companyId): ?array + public static function get(User $user, string $companyId): ?array { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); try { $cached = Cache::get($cacheKey); if ($cached) { Log::debug('User cache hit', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, ]); @@ -57,7 +54,7 @@ public static function get($userId, string $companyId): ?array } catch (\Exception $e) { Log::error('Failed to get user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); @@ -67,19 +64,17 @@ public static function get($userId, string $companyId): ?array /** * Store user data in cache. - * - * @param int|string $userId */ - public static function put($userId, string $companyId, array $data, ?int $ttl = null): bool + public static function put(User $user, string $companyId, array $data, ?int $ttl = null): bool { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); $ttl = $ttl ?? self::CACHE_TTL; try { Cache::put($cacheKey, $data, $ttl); Log::debug('User cache stored', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, 'ttl' => $ttl, @@ -89,7 +84,7 @@ public static function put($userId, string $companyId, array $data, ?int $ttl = } catch (\Exception $e) { Log::error('Failed to store user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); @@ -108,7 +103,7 @@ public static function invalidateUser(User $user): void // Clear cache for each company foreach ($companies as $companyId) { - $cacheKey = self::getCacheKey($user->id, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); Cache::forget($cacheKey); Log::debug('User cache invalidated', [ @@ -121,7 +116,7 @@ public static function invalidateUser(User $user): void // Also clear for current session company if different $sessionCompany = session('company'); if ($sessionCompany && !in_array($sessionCompany, $companies)) { - $cacheKey = self::getCacheKey($user->id, $sessionCompany); + $cacheKey = self::getCacheKey($user, $sessionCompany); Cache::forget($cacheKey); Log::debug('User cache invalidated for session company', [ @@ -140,25 +135,23 @@ public static function invalidateUser(User $user): void /** * Invalidate cache for a specific user and company. - * - * @param int|string $userId */ - public static function invalidate($userId, string $companyId): void + public static function invalidate(User $user, string $companyId): void { - $cacheKey = self::getCacheKey($userId, $companyId); + $cacheKey = self::getCacheKey($user, $companyId); try { Cache::forget($cacheKey); Log::debug('User cache invalidated', [ - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, 'cache_key' => $cacheKey, ]); } catch (\Exception $e) { Log::error('Failed to invalidate user cache', [ 'error' => $e->getMessage(), - 'user_id' => $userId, + 'user_id' => $user->uuid, 'company_id' => $companyId, ]); } diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index e290269a..79240392 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -556,11 +556,19 @@ public function updateRecord(Request $request, string $id) public function deleteRecord($id, Request $request) { if (Http::isInternalRequest($request)) { - $key = $this->model->getKeyName(); - $builder = $this->model->where($key, $id); + $key = $this->model->getKeyName(); + $builder = $this->model->where($key, $id); } else { $builder = $this->model->wherePublicId($id); } + + // Defence-in-depth: scope delete to the caller's company to prevent + // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->model->isColumn($this->model->qualifyColumn('company_uuid'))) { + $builder->where($this->model->qualifyColumn('company_uuid'), $companyUuid); + } + $builder = $this->model->applyDirectivesToQuery($request, $builder); $dataModel = $builder->first(); diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 3b7f088a..31cebf92 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -373,6 +373,14 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo $q->orWhere($publicIdColumn, $id); } }); + + // Defence-in-depth: scope update to the caller's company to prevent + // cross-tenant modification (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + $builder = $this->applyDirectivesToQuery($request, $builder); $record = $builder->first(); @@ -484,6 +492,13 @@ public function bulkRemove($ids = []) } }); + // Defence-in-depth: scope bulk delete to the caller's company to prevent + // cross-tenant deletion (GHSA-3wj9-hh56-7fw7). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $records->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + if (!$records) { return false; } @@ -721,6 +736,12 @@ public function applySorts($request, $builder) /** * Retrieves a record based on primary key id. * + * The query is automatically scoped to the current company via the + * CompanyScope global scope registered on the base Model. This method + * adds an explicit defence-in-depth company_uuid check as well so that + * the constraint is visible at the call-site and survives any future + * withoutGlobalScope() calls higher up the stack. + * * @param string $id - The ID * @param Request $request - HTTP Request * @@ -737,6 +758,15 @@ public function getById($id, ?callable $queryCallback = null, Request $request) } }); + // Defence-in-depth: explicitly scope to the caller's company when the + // model has a company_uuid column and a session company is available. + // The CompanyScope global scope provides the primary protection; this + // explicit clause ensures the constraint survives withoutGlobalScope(). + $companyUuid = session('company'); + if ($companyUuid && $this->isColumn($this->qualifyColumn('company_uuid'))) { + $builder->where($this->qualifyColumn('company_uuid'), $companyUuid); + } + if (is_callable($queryCallback)) { $queryCallback($builder, $request); } @@ -1300,7 +1330,7 @@ public static function findRecordOrFail($id, $with = [], $columns = ['*'], ?\Clo // has internal id? $hasInternalId = in_array('internal_id', $instance->getFillable()); - // create query + // create query — CompanyScope global scope is applied automatically $query = static::query() ->select($columns) ->with($with) @@ -1314,6 +1344,13 @@ function ($query) use ($id, $hasInternalId) { } ); + // Defence-in-depth: explicitly scope to the caller's company when the + // model's table has a company_uuid column and a session is active. + $companyUuid = session('company'); + if ($companyUuid && Schema::hasColumn($instance->getTable(), 'company_uuid')) { + $query->where($instance->qualifyColumn('company_uuid'), $companyUuid); + } + // more query modifications if callback supplied if (is_callable($queryCallback)) { $queryCallback($query); diff --git a/src/Types/Currency.php b/src/Types/Currency.php index 76f1c9b7..1339cb3a 100644 --- a/src/Types/Currency.php +++ b/src/Types/Currency.php @@ -596,6 +596,15 @@ class Currency implements \JsonSerializable 'decimalSeparator' => '.', 'symbolPlacement' => 'before', ], + 'KZT' => [ + 'code' => 'KZT', + 'title' => 'Kazakhstani Tenge', + 'symbol' => '₸', + 'precision' => 2, + 'thousandSeparator' => ' ', + 'decimalSeparator' => '.', + 'symbolPlacement' => 'after', + ], 'KES' => [ 'code' => 'KES', 'title' => 'Kenyan Shilling', diff --git a/views/mail/verification.blade.php b/views/mail/verification.blade.php index ada64df5..f326bdc4 100644 --- a/views/mail/verification.blade.php +++ b/views/mail/verification.blade.php @@ -20,9 +20,9 @@ @endif @if($type === 'email_verification') - @component('mail::button', ['url' => \Fleetbase\Support\Utils::consoleUrl('onboard', ['step' => 'verify-email', 'session' => base64_encode($user->uuid), 'code' => $code ])]) - Verify Email - @endcomponent +@component('mail::button', ['url' => \Fleetbase\Support\Utils::consoleUrl('onboard', ['step' => 'verify-email', 'session' => base64_encode($user->uuid), 'code' => $code ])]) +Verify Email +@endcomponent @endif