Skip to content

Commit 438c9d2

Browse files
authored
Merge pull request #189 from fleetbase/dev-v1.6.36
v1.6.36
2 parents a2e70e1 + 3651958 commit 438c9d2

25 files changed

Lines changed: 583 additions & 117 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fleetbase/core-api",
3-
"version": "1.6.35",
3+
"version": "1.6.36",
44
"description": "Core Framework and Resources for Fleetbase API",
55
"keywords": [
66
"fleetbase",

config/fleetbase.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@
3535
],
3636
'version' => env('FLEETBASE_VERSION', '0.7.1'),
3737
'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null),
38+
39+
/*
40+
|--------------------------------------------------------------------------
41+
| SMS Authentication Bypass Code
42+
|--------------------------------------------------------------------------
43+
|
44+
| This value allows a configurable bypass code for SMS-based authentication,
45+
| intended strictly for testing and development environments. It MUST be
46+
| left null (unset) in production. When null or empty, no bypass is
47+
| permitted and only the genuine Redis-stored OTP will be accepted.
48+
|
49+
| Environment variable: SMS_AUTH_BYPASS_CODE
50+
|
51+
*/
52+
'sms_auth_bypass_code' => env('SMS_AUTH_BYPASS_CODE'),
53+
3854
'user_cache' => [
3955
'enabled' => env('USER_CACHE_ENABLED', true),
4056
'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes

src/Http/Controllers/Internal/v1/AuthController.php

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,51 @@ public function login(LoginRequest $request)
4646
$password = $request->input('password');
4747
$authToken = $request->input('authToken');
4848

49-
// if attempting to authenticate with auth token validate it first against database and respond with it
49+
// If an existing auth token is provided, attempt to re-authenticate with it.
50+
// The token must be valid AND must belong to the user identified by the
51+
// 'identity' field in this request, preventing token-swap attacks where a
52+
// token from one user could be used to authenticate as another.
5053
if ($authToken) {
5154
$personalAccessToken = PersonalAccessToken::findToken($authToken);
52-
$personalAccessToken->loadMissing('tokenable');
5355

5456
if ($personalAccessToken) {
55-
return response()->json(['token' => $authToken, 'type' => $personalAccessToken->tokenable instanceof User ? $personalAccessToken->tokenable->getType() : null]);
57+
$personalAccessToken->loadMissing('tokenable');
58+
$tokenOwner = $personalAccessToken->tokenable;
59+
60+
if (
61+
$tokenOwner instanceof User
62+
&& ($tokenOwner->email === $identity || $tokenOwner->phone === $identity)
63+
) {
64+
return response()->json([
65+
'token' => $authToken,
66+
'type' => $tokenOwner->getType(),
67+
]);
68+
}
5669
}
70+
71+
// If the token is invalid or does not match the claimed identity, fall
72+
// through silently to normal password-based authentication. Do not
73+
// return an error here to avoid leaking whether the token exists.
5774
}
5875

5976
// Find the user using the identity provided
6077
$user = User::where(function ($query) use ($identity) {
6178
$query->where('email', $identity)->orWhere('phone', $identity);
6279
})->first();
6380

64-
if (!$user) {
65-
return response()->error('No user found by the provided identity.', 401, ['code' => 'no_user']);
81+
// If the user exists but has no password set (e.g. SSO-invited or provisioned
82+
// accounts), silently fall through to the generic credentials error below.
83+
// This guard MUST come before isInvalidPassword() which has a strict string
84+
// type declaration on $hashedPassword and would throw a TypeError on null.
85+
// We do NOT return a distinct error here to avoid leaking account state.
86+
if ($user && empty($user->password)) {
87+
$user = null;
88+
}
89+
90+
// Use a generic error message for both non-existent user and wrong password
91+
// to prevent user enumeration via differential error responses.
92+
if (!$user || Auth::isInvalidPassword($password, $user->password)) {
93+
return response()->error('These credentials do not match our records.', 401, ['code' => 'invalid_credentials']);
6694
}
6795

6896
// Check if 2FA enabled
@@ -75,15 +103,6 @@ public function login(LoginRequest $request)
75103
]);
76104
}
77105

78-
// If no password prompt user to reset password
79-
if (empty($user->password)) {
80-
return response()->error('Password reset required to continue.', 400, ['code' => 'reset_password']);
81-
}
82-
83-
if (Auth::isInvalidPassword($password, $user->password)) {
84-
return response()->error('Authentication failed using password provided.', 401, ['code' => 'invalid_password']);
85-
}
86-
87106
if ($user->isNotVerified() && $user->isNotAdmin()) {
88107
return response()->error('User is not verified.', 400, ['code' => 'not_verified']);
89108
}
@@ -274,13 +293,13 @@ public function sendVerificationSms(Request $request)
274293

275294
// Send user their verification code
276295
try {
277-
Twilio::message($queryPhone, shell_exec('Your Fleetbase authentication code is ') . $verifyCode);
296+
Twilio::message($queryPhone, 'Your Fleetbase authentication code is ' . $verifyCode);
278297
} catch (\Exception|\Twilio\Exceptions\RestException $e) {
279298
return response()->json(['error' => $e->getMessage()], 400);
280299
}
281300

282-
// Store verify code for this number
283-
Redis::set($verifyCodeKey, $verifyCode);
301+
// Store verify code for this number with a 10-minute TTL to prevent replay attacks
302+
Redis::setex($verifyCodeKey, 600, $verifyCode);
284303

285304
// 200 OK
286305
return response()->json(['status' => 'OK']);
@@ -308,11 +327,22 @@ public function authenticateSmsCode(Request $request)
308327
$verifyCode = $request->input('code');
309328
$verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_');
310329

311-
// Generate hto
330+
// Retrieve the stored verification code from Redis
312331
$storedVerifyCode = Redis::get($verifyCodeKey);
313332

314-
// Verify
315-
if ($verifyCode !== '000999' && $verifyCode !== $storedVerifyCode) {
333+
// Retrieve the optional testing bypass code from configuration.
334+
// This is configurable via the SMS_AUTH_BYPASS_CODE environment variable
335+
// and is intended for development/testing environments only.
336+
// It MUST be left unset (null) in production deployments.
337+
$bypassCode = config('fleetbase.sms_auth_bypass_code');
338+
339+
// Verify the submitted code against the stored OTP using a constant-time
340+
// comparison to prevent timing attacks. If a bypass code is configured
341+
// and the environment is not production, also allow that code.
342+
$isValidOtp = !empty($storedVerifyCode) && hash_equals((string) $storedVerifyCode, (string) $verifyCode);
343+
$isBypassValid = !empty($bypassCode) && !app()->environment('production') && hash_equals((string) $bypassCode, (string) $verifyCode);
344+
345+
if (!$isValidOtp && !$isBypassValid) {
316346
return response()->error('Invalid verification code');
317347
}
318348

src/Http/Controllers/Internal/v1/InstallerController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public function migrate()
103103
ini_set('memory_limit', '-1');
104104
ini_set('max_execution_time', 0);
105105

106-
shell_exec(base_path('artisan') . ' migrate');
106+
Artisan::call('migrate', ['--force' => true]);
107107
Artisan::call('sandbox:migrate');
108108

109109
// Clear cache after migration

src/Http/Controllers/Internal/v1/UserController.php

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Fleetbase\Http\Requests\Internal\ResendUserInvite;
1515
use Fleetbase\Http\Requests\Internal\UpdatePasswordRequest;
1616
use Fleetbase\Http\Requests\Internal\ValidatePasswordRequest;
17+
use Fleetbase\Http\Requests\UpdateUserRequest;
1718
use Fleetbase\Models\Company;
1819
use Fleetbase\Models\CompanyUser;
1920
use Fleetbase\Models\Invite;
@@ -57,6 +58,16 @@ class UserController extends FleetbaseController
5758
*/
5859
public $createRequest = CreateUserRequest::class;
5960

61+
/**
62+
* Update user request.
63+
*
64+
* Enforces that email and phone cannot be set to an empty string
65+
* and that uniqueness constraints are respected on update.
66+
*
67+
* @var UpdateUserRequest
68+
*/
69+
public $updateRequest = UpdateUserRequest::class;
70+
6071
/**
6172
* Creates a record with request payload.
6273
*
@@ -126,6 +137,11 @@ public function createRecord(Request $request)
126137
*/
127138
public function updateRecord(Request $request, string $id)
128139
{
140+
// Run the UpdateUserRequest validation rules before delegating to the
141+
// model trait. This prevents email/phone being set to an empty string
142+
// and enforces uniqueness constraints on partial (PATCH) updates.
143+
$this->validateRequest($request);
144+
129145
try {
130146
$record = $this->model->updateRecordFromRequest($request, $id, function (&$request, &$user) {
131147
// Assign role if set
@@ -182,7 +198,7 @@ public function current(Request $request)
182198

183199
// Try to get from server cache
184200
$companyId = session('company');
185-
$cachedData = UserCacheService::get($user->id, $companyId);
201+
$cachedData = UserCacheService::get($user, $companyId);
186202

187203
if ($cachedData) {
188204
// Return cached data with cache headers
@@ -202,7 +218,7 @@ public function current(Request $request)
202218
$userArray = $userData->toArray($request);
203219

204220
// Store in cache
205-
UserCacheService::put($user->id, $companyId, $userArray);
221+
UserCacheService::put($user, $companyId, $userArray);
206222

207223
// Return with cache headers
208224
return response()->json(['user' => $userArray])
@@ -404,18 +420,56 @@ public function deactivate($id)
404420
return response()->error('No user to deactivate', 401);
405421
}
406422

407-
$user = User::where('uuid', $id)->first();
423+
$currentUser = request()->user();
424+
425+
// Scope the lookup to the current company to prevent cross-organization IDOR.
426+
$user = User::where('uuid', $id)
427+
->whereHas('companyUsers', function ($query) {
428+
$query->where('company_uuid', session('company'));
429+
})
430+
->first();
408431

409432
if (!$user) {
410-
return response()->error('No user found', 401);
433+
return response()->error('No user found', 404);
411434
}
412435

413-
$user->deactivate();
414-
$user = $user->refresh();
436+
// Prevent a user from deactivating their own account via this endpoint.
437+
if ($currentUser && $currentUser->uuid === $user->uuid) {
438+
return response()->error('You cannot deactivate your own account.', 403);
439+
}
440+
441+
// Layered privilege check:
442+
//
443+
// Tier 1 — System admins (isAdmin()) can deactivate anyone except other
444+
// system admins. This is the highest privilege tier.
445+
if ($user->isAdmin()) {
446+
return response()->error('Insufficient permissions to deactivate this user.', 403);
447+
}
448+
449+
// Tier 2 — Users holding the 'Administrator' role can only be deactivated
450+
// by a system admin (handled above). A regular user or another
451+
// role-based Administrator cannot deactivate them.
452+
if ($user->hasRole('Administrator') && $currentUser && !$currentUser->isAdmin()) {
453+
return response()->error('Insufficient permissions to deactivate this user.', 403);
454+
}
455+
456+
// Only deactivate the CompanyUser record for the current organisation.
457+
// Calling User::deactivate() would set the user's global status to
458+
// 'inactive', locking them out of every organisation they belong to.
459+
// Instead we update only the pivot record so the user remains active
460+
// in any other organisations they are a member of.
461+
$companyUser = $user->companyUsers()->where('company_uuid', session('company'))->first();
462+
463+
if (!$companyUser) {
464+
return response()->error('User is not a member of this organisation.', 404);
465+
}
466+
467+
$companyUser->status = 'inactive';
468+
$companyUser->save();
415469

416470
return response()->json([
417471
'message' => 'User deactivated',
418-
'status' => $user->session_status,
472+
'status' => $companyUser->status,
419473
]);
420474
}
421475

@@ -430,12 +484,24 @@ public function activate($id)
430484
return response()->error('No user to activate', 401);
431485
}
432486

433-
$user = User::where('uuid', $id)->first();
487+
$currentUser = request()->user();
488+
489+
// Scope the lookup to the current company to prevent cross-organisation IDOR.
490+
$user = User::where('uuid', $id)
491+
->whereHas('companyUsers', function ($query) {
492+
$query->where('company_uuid', session('company'));
493+
})
494+
->first();
434495

435496
if (!$user) {
436-
return response()->error('No user found', 401);
497+
return response()->error('No user found', 404);
437498
}
438499

500+
// Activate both the User record and the CompanyUser record.
501+
// Unlike deactivation (which is scoped to the current organisation only),
502+
// activation must also update users.status because a newly created user
503+
// starts with a global status of 'inactive' and needs to be unblocked
504+
// at the user level before they can access any organisation.
439505
$user->activate();
440506
$user = $user->refresh();
441507

src/Http/Filter/Filter.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,18 @@ private function applyFilter($name, $value)
145145
}
146146
}
147147

148-
// Check if it's an expansion (only if method not found)
149-
if (!$this->methodCache[$cacheKey] && static::isExpansion($name)) {
150-
$this->methodCache[$cacheKey] = $name;
148+
// Check if it's an expansion (only if method not found).
149+
// Expansions are registered under their PHP method name (camelCase), so we must
150+
// check both the raw param name (e.g. 'doesnt_have_driver') and its camelCase
151+
// equivalent (e.g. 'doesntHaveDriver') to correctly resolve snake_case query
152+
// params that map to camelCase expansion methods.
153+
if (!$this->methodCache[$cacheKey]) {
154+
$camelName = Str::camel($name);
155+
if (static::isExpansion($name)) {
156+
$this->methodCache[$cacheKey] = $name;
157+
} elseif (static::isExpansion($camelName)) {
158+
$this->methodCache[$cacheKey] = $camelName;
159+
}
151160
}
152161
}
153162

src/Http/Filter/UserFilter.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public function isNotAdmin()
2929
$this->builder->where('type', '!=', 'admin');
3030
}
3131

32+
public function isUser()
33+
{
34+
$this->builder->whereIn('type', ['user', 'admin']);
35+
}
36+
3237
public function query(?string $query)
3338
{
3439
$this->builder->search($query);
@@ -51,8 +56,11 @@ public function email(?string $email)
5156

5257
public function role(?string $roleId)
5358
{
54-
$this->builder->whereHas('roles', function ($query) use ($roleId) {
55-
$query->where('id', $roleId);
59+
$this->builder->whereHas('companyUsers', function ($query) use ($roleId) {
60+
$query->where('company_uuid', session('company'));
61+
$query->whereHas('roles', function ($query) use ($roleId) {
62+
$query->where('id', $roleId);
63+
});
5664
});
5765
}
5866
}

src/Http/Requests/Internal/ResetPasswordRequest.php

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Fleetbase\Http\Requests\Internal;
44

55
use Fleetbase\Http\Requests\FleetbaseRequest;
6+
use Illuminate\Validation\Rules\Password;
67

78
class ResetPasswordRequest extends FleetbaseRequest
89
{
@@ -26,8 +27,18 @@ public function rules()
2627
return [
2728
'code' => ['required', 'exists:verification_codes,code'],
2829
'link' => ['required', 'exists:verification_codes,uuid'],
29-
'password' => ['required', 'confirmed', 'min:4', 'max:24'],
30-
'password_confirmation' => ['required', 'min:4', 'max:24'],
30+
'password' => [
31+
'required',
32+
'confirmed',
33+
'string',
34+
Password::min(8)
35+
->mixedCase()
36+
->letters()
37+
->numbers()
38+
->symbols()
39+
->uncompromised(),
40+
],
41+
'password_confirmation' => ['required', 'string'],
3142
];
3243
}
3344

@@ -39,8 +50,15 @@ public function rules()
3950
public function messages()
4051
{
4152
return [
42-
'code' => 'Invalid password reset request!',
43-
'link' => 'Invalid password reset request!',
53+
'code' => 'Invalid password reset request!',
54+
'link' => 'Invalid password reset request!',
55+
'password.required' => 'You must enter a password.',
56+
'password.min' => 'Password must be at least 8 characters.',
57+
'password.mixed' => 'Password must contain both uppercase and lowercase letters.',
58+
'password.letters' => 'Password must contain at least one letter.',
59+
'password.numbers' => 'Password must contain at least one number.',
60+
'password.symbols' => 'Password must contain at least one symbol.',
61+
'password.uncompromised' => 'This password has appeared in a data breach. Please choose a different one.',
4462
];
4563
}
4664
}

0 commit comments

Comments
 (0)