Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0d62fba
feat: Include updated_at timestamp in UserCacheService cache key
Feb 11, 2026
9736e26
v1.6.36
roncodes Feb 13, 2026
fe3c5ca
Merge pull request #188 from fleetbase/feature/user-cache-updated-at-…
roncodes Feb 13, 2026
385e8bb
added KZT currency
Feb 10, 2026
b8b4505
fix: Set default status to 'pending' for verification codes
Feb 24, 2026
aee53ae
Merge pull request #190 from spanchenko/feature/add-KZT-currency
roncodes Feb 24, 2026
e8a9152
Merge pull request #191 from fleetbase/fix/verification-code-default-…
roncodes Feb 24, 2026
27d55ab
fix: Verification email showing raw HTML code
Feb 24, 2026
9eb4375
fix: Use build() method instead of content() for markdown mail compat…
Feb 24, 2026
ba8781d
Merge pull request #192 from fleetbase/fix/verification-email-html-re…
roncodes Feb 24, 2026
b8039b9
security: patch GHSA-3wj9-hh56-7fw7 and related vulnerabilities
Feb 25, 2026
492f26a
fix: restore authToken re-authentication with identity verification
Feb 25, 2026
c72e31d
security: fix systemic tenant isolation bypass (GHSA-3wj9-hh56-7fw7)
Feb 25, 2026
ed9cdb8
fix: resolve camelCase expansion methods from snake_case query params…
roncodes Feb 26, 2026
adc5c70
Merge pull request #193 from fleetbase/fix/security-advisory-GHSA-3wj…
roncodes Feb 26, 2026
d6db69e
fix: prevent TypeError when user has null password in login()
Feb 27, 2026
0e88d75
fix: use correct layered privilege check in UserController::deactivate()
Feb 27, 2026
3c2d87d
fix: scope deactivate/activate to CompanyUser only, not the User record
Feb 27, 2026
6691b47
fix: restore User::activate() in activate() endpoint to update both u…
Feb 27, 2026
8428dcb
ran linter
roncodes Feb 27, 2026
75bdd18
fix: prevent empty email/phone on user update via UpdateUserRequest
Feb 27, 2026
1c4ffe9
Merge pull request #195 from fleetbase/fix/prevent-empty-email-phone-…
roncodes Feb 27, 2026
3651958
ran linter added is_user filter param
roncodes Feb 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions config/fleetbase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 50 additions & 20 deletions src/Http/Controllers/Internal/v1/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,51 @@ 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
$user = User::where(function ($query) use ($identity) {
$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
Expand All @@ -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']);
}
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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');
}

Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/Internal/v1/InstallerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 75 additions & 9 deletions src/Http/Controllers/Internal/v1/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -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,
]);
}

Expand All @@ -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();

Expand Down
15 changes: 12 additions & 3 deletions src/Http/Filter/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/Http/Filter/UserFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});
}
}
26 changes: 22 additions & 4 deletions src/Http/Requests/Internal/ResetPasswordRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Http\Requests\Internal;

use Fleetbase\Http\Requests\FleetbaseRequest;
use Illuminate\Validation\Rules\Password;

class ResetPasswordRequest extends FleetbaseRequest
{
Expand All @@ -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'],
];
}

Expand All @@ -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.',
];
}
}
Loading