Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions config/sms.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@
'alert_percentages' => [50, 80],
'distinct_destinations_per_10m' => 25,
'auto_pauses_before_manual' => 2,
// Sends in 10 minutes to a country the company has not texted in the last
// 30 days, once it has at least `new_country_min_history` ledgered sends.
'new_country_min_sends' => 10,
'new_country_min_history' => 50,
// Verification codes issued by SMS in the last hour versus codes actually
// verified: pumping has a conversion of about zero.
'conversion_min_issued' => 20,
'conversion_min_percent' => 10,
],

// The send ledger (sms_sends): every send and refusal with who, where and why.
'ledger' => [
'retention_days' => env('SMS_GUARD_LEDGER_RETENTION_DAYS', 90),
'mask_after_days' => env('SMS_GUARD_LEDGER_MASK_AFTER_DAYS', 30),
],

// Per-purpose overrides, keyed by the verification code's `for` value.
Expand Down
56 changes: 56 additions & 0 deletions migrations/2026_10_13_000001_create_sms_sends_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* The SMS send ledger: one row per send the guard allowed and per send it refused,
* with the tenant, the purpose, where the request came from and how the provider
* answered. It is what lets an incident be traced from the database in one query,
* and what the breaker's new-country and usage views read.
*/
public function up(): void
{
Schema::create('sms_sends', function (Blueprint $table) {
$table->uuid('uuid')->primary();
$table->char('company_uuid', 36)->nullable()->index();
$table->string('tenant_type', 32)->nullable();
$table->char('tenant_uuid', 36)->nullable()->index();
$table->string('purpose', 64)->index();
$table->string('source', 32);
$table->string('destination', 32)->nullable();
$table->string('destination_hash', 64)->nullable()->index();
$table->char('destination_country', 3)->nullable()->index();
$table->string('destination_type', 32)->nullable();
$table->string('status', 16)->index();
$table->string('block_reason', 64)->nullable();
$table->string('provider', 32)->nullable();
$table->string('provider_message_id', 128)->nullable();
$table->text('error')->nullable();
$table->string('subject_type')->nullable();
$table->char('subject_uuid', 36)->nullable();
$table->char('verification_code_uuid', 36)->nullable()->index();
$table->string('ip_address', 45)->nullable()->index();
$table->string('user_agent', 512)->nullable();
$table->json('meta')->nullable();
$table->timestamps();

$table->index(['company_uuid', 'created_at']);
$table->index(['tenant_uuid', 'created_at']);
$table->index(['status', 'created_at']);
$table->index('created_at');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sms_sends');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* Verification codes get real, indexed columns for what used to live only in `meta`:
* the tenant that asked for the code, the identity it was filed under, the channel it
* went out on, where the request came from, the ledger row of the SMS that carried it,
* and when it was used. `identity` is backfilled from `meta->identity` for recent rows
* so lookups can move off the JSON path at once.
*/
public function up(): void
{
Schema::table('verification_codes', function (Blueprint $table) {
$table->char('company_uuid', 36)->nullable()->index()->after('subject_type');
$table->string('identity', 191)->nullable()->after('company_uuid');
$table->string('channel', 16)->nullable()->after('identity');
$table->string('ip_address', 45)->nullable()->after('channel');
$table->string('user_agent', 512)->nullable()->after('ip_address');
$table->char('sms_send_uuid', 36)->nullable()->index()->after('user_agent');
$table->dateTime('consumed_at')->nullable()->after('expires_at');

$table->index(['for', 'identity', 'created_at']);
});

if (DB::connection()->getDriverName() === 'mysql') {
DB::table('verification_codes')
->whereNull('identity')
->where('created_at', '>=', now()->subDays(30))
->update(['identity' => DB::raw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.identity'))")]);
}
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('verification_codes', function (Blueprint $table) {
$table->dropIndex(['for', 'identity', 'created_at']);
$table->dropColumn(['company_uuid', 'identity', 'channel', 'ip_address', 'user_agent', 'sms_send_uuid', 'consumed_at']);
});
}
};
16 changes: 2 additions & 14 deletions src/Console/Commands/SmsGuardPause.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Fleetbase\Console\Commands;

use Fleetbase\Models\Company;
use Fleetbase\Support\Sms\SmsBreaker;
use Fleetbase\Support\Sms\SmsGuard;
use Illuminate\Console\Command;
Expand Down Expand Up @@ -46,22 +45,11 @@ public function handle(): int
*/
public static function resolveScope(string $input): ?string
{
$input = trim($input);

if ($input === SmsBreaker::SCOPE_PLATFORM || str_starts_with($input, 'company:') || str_starts_with($input, 'tenant:')) {
return $input;
}

$company = Company::withoutGlobalScopes()
->where('uuid', $input)
->orWhere('public_id', $input)
->first(['uuid']);

return $company ? 'company:' . $company->uuid : null;
return SmsBreaker::resolveScope($input);
}

public static function companyUuidFromScope(string $scope): ?string
{
return str_starts_with($scope, 'company:') ? substr($scope, strlen('company:')) : null;
return SmsBreaker::companyUuidFromScope($scope);
}
}
36 changes: 36 additions & 0 deletions src/Console/Commands/SmsLedgerPrune.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Fleetbase\Console\Commands;

use Fleetbase\Support\Sms\SmsLedger;
use Illuminate\Console\Command;

/**
* Apply the SMS ledger's retention: mask numbers on older rows and delete old ones.
*/
class SmsLedgerPrune extends Command
{
protected $signature = 'sms:ledger:prune
{--days= : delete rows older than this many days (default: sms.guard.ledger.retention_days)}
{--mask-after= : mask the destination on rows older than this many days (default: sms.guard.ledger.mask_after_days)}';

protected $description = 'Mask and delete old SMS ledger rows according to the retention settings';

public function handle(): int
{
$retention = $this->option('days') !== null ? (int) $this->option('days') : (int) config('sms.guard.ledger.retention_days', 90);
$maskAfter = $this->option('mask-after') !== null ? (int) $this->option('mask-after') : (int) config('sms.guard.ledger.mask_after_days', 30);

try {
$result = SmsLedger::prune($retention, $maskAfter);
} catch (\Throwable $e) {
$this->error('The SMS ledger could not be pruned: ' . $e->getMessage());

return self::FAILURE;
}

$this->info(sprintf('SMS ledger: masked %d row(s) older than %d day(s), deleted %d row(s) older than %d day(s).', $result['masked'], $maskAfter, $result['deleted'], $retention));

return self::SUCCESS;
}
}
163 changes: 0 additions & 163 deletions src/Http/Controllers/Internal/v1/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
use Fleetbase\Support\Auth;
use Fleetbase\Support\TwoFactorAuth;
use Fleetbase\Support\Utils;
use Fleetbase\Twilio\Support\Laravel\Facade as Twilio;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
Expand Down Expand Up @@ -242,133 +241,6 @@ public function bootstrap(Request $request)
->header('Cache-Control', 'private, max-age=300');
}

/**
* Send a verification SMS code.
*
* @param \\Illuminate\Http\Request $request
*
* @return \Illuminate\Http\Response $response
*/
public function sendVerificationSms(Request $request)
{
// Users phone number
$phone = $queryPhone = $request->input('phone');
$countryCode = $request->input('countryCode');
$for = $request->input('driver');

// set phone number
if (!Str::startsWith($queryPhone, '+')) {
$queryPhone = '+' . $countryCode . $phone;
}

// Make sure user exists with phone number
$userExistsQuery = User::where('phone', $queryPhone)->whereNull('deleted_at')->withoutGlobalScopes();

if ($for === 'driver') {
$userExistsQuery->where('type', 'driver');
}

$userExists = $userExistsQuery->exists();

if (!$userExists) {
return response()->error('No user with this phone # found.');
}

// Generate hto
$verifyCode = mt_rand(100000, 999999);
$verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_');

// Send user their verification code
try {
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 with a 10-minute TTL to prevent replay attacks
Redis::setex($verifyCodeKey, 600, $verifyCode);

// 200 OK
return response()->json(['status' => 'OK']);
}

/**
* Authenticate a user with SMS code.
*
* @param \\Illuminate\Http\Request $request
*
* @return \Illuminate\Http\Response $response
*/
public function authenticateSmsCode(Request $request)
{
// Users phone number
$phone = $queryPhone = $request->input('phone');
$countryCode = $request->input('countryCode');

// set phone number
if (!Str::startsWith($queryPhone, '+')) {
$queryPhone = '+' . $countryCode . $phone;
}

// Users verfiy code entered
$verifyCode = $request->input('code');
$verifyCodeKey = Str::slug($queryPhone . '_verify_code', '_');

// Retrieve the stored verification code from Redis
$storedVerifyCode = Redis::get($verifyCodeKey);

// 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');
}

// Remove from redis
Redis::del($verifyCodeKey);

// get user for phone number
$user = User::where('phone', $queryPhone)->first();

// Attempt authentication
if ($user) {
// Set authenticatin user
Auth::login($user);

// Generate token
try {
$token = $user->createToken($user->phone)->plainTextToken;
} catch (\Exception $e) {
return response()->error($e->getMessage());
}

// @codeCoverageIgnoreStart
// The driver relation is supplied by Fleet-Ops; core-api tests cannot load that package relation directly.
if ($user->type === 'driver') {
$user->load(['driver']);
}
// @codeCoverageIgnoreEnd

// Send message to notify users authentication
return response()->json([
'token' => $token,
'user' => $user,
]);
}

// If unable to authenticate user, respond with error
return response()->json('Authentication failed', 401);
}

/**
* Create resend verification code session.
*
Expand Down Expand Up @@ -516,41 +388,6 @@ public function verifyEmail(Request $request)
]);
}

/**
* Allow user to verify SMS code.
*
* @param \\Illuminate\Http\Request $request
*
* @return \Illuminate\Http\Response $response
*/
public function verifySmsCode(Request $request)
{
// Users phone number
$phone = $request->input('phone');

// Users verfiy code entered
$verifyCode = $request->input('code');
$verifyCodeKey = Str::slug($phone . '_verify_code', '_');

// Generate hto
$storedVerifyCode = Redis::get($verifyCodeKey);

// Verify
if ($verifyCode === $storedVerifyCode) {
// Remove from redis
Redis::del($verifyCodeKey);

// 200 OK
return response()->json([
'status' => 'OK',
'message' => 'Code verified',
]);
}

// 400 ERROR
return response()->error('Invalid verification code');
}

/**
* Creates a new company and user account.
*
Expand Down
Loading