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
43 changes: 6 additions & 37 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,11 @@
# v1.6.65 — Authenticator-app 2FA, sign-in hardening and IAM permission fixes

## Improvements

- Organization administration supports company and owner identity search, country/timezone/owner-IP and registration/update-date filters, and sorting by current user count. Admins can open organizations outside their own memberships, inspect organization-scoped usage totals, and see members' 2FA methods and linked OAuth providers without exposing authentication secrets.
- Sign in with an authenticator app (TOTP, RFC 6238), next to email and SMS 2FA (#163). It works with Authy, Google Authenticator, Microsoft Authenticator and 1Password.
- New `users/two-fa/authenticator` endpoints to set up, confirm, disable and inspect the app. Setup, disable and regenerating recovery codes need the current password.
- Confirming the app returns 8 single-use recovery codes. `two-fa/verify` accepts an app code (±1 step for clock drift, each code once) or a recovery code.
- `two-fa/resend` falls back to an emailed (or SMS) code and returns the new `method`.
- The secret is encrypted with the app key, and recovery codes are stored as keyed hashes.
- New `Fleetbase\Support\Barcode` helper with a compact `qrCodeSvg()`.
- Record the app, APNs environment and last-seen time on user devices. New nullable `user_devices` columns: `app_identifier` (indexed), `environment` and `last_seen_at`.
- New organization setting to let users change their own password (default on), at `GET/POST companies/auth-settings`. `GET users/password-policy` returns `{can_change_password}`.
# v1.6.66 — Per-consumer API rate limiting, admin rate-limit controls and API consumer metrics

## Fixes

- Settings → Notifications applies when notifications are sent from the queue or a console command (#262). `NotificationRegistry` takes the company from the notification's subject instead of the session. `notifyUsingDefinitionName()` reads the company-scoped settings instead of a global key nothing writes. New `Setting::lookupForCompany()`.
- Invited users can set their first password, and non-admins can change their own (#263). The password endpoints no longer fall through to `iam create user`.
- FCM order notifications are sent with Android `priority: high`, so drivers' phones in Doze get them immediately (#268).

## Security
- One API consumer can no longer rate-limit the whole platform. `ThrottleRequests` ran before API authentication, so Laravel keyed every bucket on the client IP. Behind a load balancer that is the balancer's IP, so every tenant, API key and console visitor shared a single 120/min bucket, and one busy integration returned 429 to everyone. The limiter now keys on the presented credential (API key, Sanctum token or basic auth), falling back to the user and then the IP only when none is sent. The first path segment keeps `/v1` and `/int` in separate buckets. (#280)
- 429 responses keep `Retry-After` and `X-RateLimit-*`. The exception handler used to drop them, so throttled clients could not back off correctly. (#280)

- A 2FA session starts only after the password is checked. `GET two-fa/check` used to start one from the identity alone, so an email plus the emailed code was enough to sign in. It now always returns `{twoFaSession: null, isTwoFaEnabled: false}` and no longer reveals whether an account uses 2FA.
- 2FA sessions expire after 10 minutes; they used to live about 56 years. The 5th wrong code deletes the session, and resending doesn't reset the count. Codes are compared in constant time and generated with `random_int`.
- `users/change-password` requires `current_password` in the same request, and `iam change-password` is enforced. `users/set-password` works only once, within 24 hours of accepting an invite. `validate-password` and `change-password` are limited to 10 requests per minute.
- Close authorization gaps where `AuthorizationGuard` resolved to permission names that don't exist:
- Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore `owner_uuid`, Stripe ids, `plan`, `status`, `trial_ends_at` and `type`.
- `POST two-fa/config` (system 2FA policy) and admin platform metrics are limited to system admins.
- IAM and developer metrics need `iam list user` / `developers list api-key`.
- Reports use the `iam` service: `iam execute report` for direct queries, `iam export report` for exports.
- API credentials, webhooks, API events and request logs check the `api-key`, `webhook`, `event` and `log` permissions.
- Password, auth-setting and authenticator changes are written to the `auth` activity log.

## Behaviour changes

- Consoles need the companion fleetbase/fleetbase changes: the 2FA sign-in flow (`fix/2fa-login-hardening`), `current_password` on change-password (fleetbase/fleetbase#685) and the authenticator-app UI (fleetbase/fleetbase#686). With an older console, users with 2FA can't sign in and self-service password changes fail.
- Users need `iam … report` permissions to use reports, and `developers …` permissions for API keys, webhooks, events and logs. Fleet-Ops' report screens check the same names in fleetbase/fleetops#345.
- A user who accepted an invite before this release but never set a password should use **Forgot password**.

Run the migrations for the new `user_devices` columns. Run `composer update` to install `pragmarx/google2fa`.
## Improvements

Changes: [#272](https://github.com/fleetbase/core-api/pull/272), [#273](https://github.com/fleetbase/core-api/pull/273), [#274](https://github.com/fleetbase/core-api/pull/274), [#275](https://github.com/fleetbase/core-api/pull/275), [#276](https://github.com/fleetbase/core-api/pull/276), [#277](https://github.com/fleetbase/core-api/pull/277), [#278](https://github.com/fleetbase/core-api/pull/278).
- System admins can manage API rate limits at runtime. The limits (`THROTTLE_*` environment defaults) can be overridden from the console and stored as the `system.rate-limits` setting: enable/disable, requests per window, and window length. **Per-organization overrides** give an organization a custom limit or none. New admin-only endpoints `GET/POST/DELETE int/v1/rate-limits/settings`. (#281)
- API consumer metrics. The throttle middleware counts every request and every 429 per consumer in Redis: minute buckets are kept for 2 hours, hour buckets for 8 days, at one pipelined round trip per request. `GET int/v1/rate-limits/consumers?window=&sort=&limit=` lists the busiest or most throttled consumers with their organization, masked key, scope, IP, avg and peak per minute, and share. `POST int/v1/rate-limits/consumers/{signature}/reset` clears one consumer's window. Tracking can be disabled with `THROTTLE_TRACK_CONSUMERS=false`; `THROTTLE_METRICS_REDIS_CONNECTION` picks the Redis connection (default `cache`). (#281)
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.65",
"version": "1.6.66",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
9 changes: 9 additions & 0 deletions config/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
// These keys can be used for performance testing in production
// Example: THROTTLE_UNLIMITED_API_KEYS=Bearer test_key_123,Bearer load_test_456
'unlimited_keys' => array_filter(explode(',', env('THROTTLE_UNLIMITED_API_KEYS', ''))),

// Count requests per API consumer for the admin "API consumers" view (needs Redis)
// Default: true (enabled). Administrators can also toggle this in the console.
// Example: THROTTLE_TRACK_CONSUMERS=false
'track_consumers' => env('THROTTLE_TRACK_CONSUMERS', true),

// Redis connection the consumer metrics are written to
// Default: cache
'metrics_connection' => env('THROTTLE_METRICS_REDIS_CONNECTION', 'cache'),
],

'cache' => [
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht
return response()->error('Invalid XSRF token sent with request.', 419);

case 'ThrottleRequestsException':
return response()->error('Too many requests.', 429);
// Keep Retry-After and X-RateLimit-* so the throttled client knows when to retry.
return response()->error('Too many requests.', 429)->withHeaders($exception->getHeaders());

case 'AuthenticationException':
return response()->error('Unauthenticated.', 401);
Expand Down
117 changes: 117 additions & 0 deletions src/Http/Controllers/Internal/v1/RateLimitController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Fleetbase\Http\Controllers\Internal\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Requests\AdminRequest;
use Fleetbase\Models\Company;
use Fleetbase\Support\ApiConsumerMetrics;
use Fleetbase\Support\ApiRateLimits;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;

/**
* System administration of API rate limiting: the limits themselves, per-organization
* overrides, and visibility into which consumers drive traffic or are being throttled.
*/
class RateLimitController extends Controller
{
/**
* The effective settings, the environment defaults beneath them, and the overrides
* with their organizations resolved for display.
*/
public function getSettings(AdminRequest $request): JsonResponse
{
return response()->json($this->settingsPayload(ApiRateLimits::settings()));
}

/**
* Save the administrator's settings; they apply from the next request.
*/
public function saveSettings(AdminRequest $request): JsonResponse
{
$validated = $request->validate([
'enabled' => ['required', 'boolean'],
'max_attempts' => ['required', 'integer', 'min:1', 'max:1000000'],
'decay_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'track_consumers' => ['sometimes', 'boolean'],
'overrides' => ['sometimes', 'array'],
'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')],
'overrides.*.unlimited' => ['sometimes', 'boolean'],
'overrides.*.max_attempts' => ['nullable', 'integer', 'min:1', 'max:1000000'],
'overrides.*.note' => ['nullable', 'string', 'max:255'],
]);

return response()->json($this->settingsPayload(ApiRateLimits::store($validated)));
}

/**
* Discard the administrator's settings and fall back to the environment.
*/
public function resetSettings(AdminRequest $request): JsonResponse
{
return response()->json($this->settingsPayload(ApiRateLimits::reset()));
}

/**
* The busiest (or most throttled) API consumers over a recent window.
*/
public function consumers(AdminRequest $request): JsonResponse
{
$request->validate([
'window' => ['sometimes', 'integer', Rule::in(ApiConsumerMetrics::WINDOWS)],
'sort' => ['sometimes', Rule::in(['hits', 'throttled'])],
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
]);

$settings = ApiRateLimits::settings();
$metrics = ApiConsumerMetrics::top(
(int) $request->input('window', 15),
(string) $request->input('sort', 'hits'),
(int) $request->input('limit', 50)
);

$metrics['tracking'] = $settings['track_consumers'];
$metrics['default_limit'] = $settings['max_attempts'];
$metrics['decay_minutes'] = $settings['decay_minutes'];

return response()->json($metrics);
}

/**
* Clear a consumer's current limiter window so it can send requests again at once.
*/
public function resetConsumer(AdminRequest $request, string $signature): JsonResponse
{
if (!preg_match('/^[a-f0-9]{40}$/', $signature)) {
return response()->error('Invalid consumer.', 422);
}

RateLimiter::clear($signature);

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

protected function settingsPayload(array $settings): array
{
$companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid'))
->get(['uuid', 'public_id', 'name'])
->keyBy('uuid');

$settings['overrides'] = array_map(function (array $override) use ($companies) {
$company = $companies->get($override['company_uuid']);

return array_merge($override, [
'company_id' => $company?->public_id,
'company_name' => $company?->name,
]);
}, $settings['overrides']);

return [
'settings' => $settings,
'defaults' => ApiRateLimits::defaults(),
'unlimited_keys' => count(config('api.throttle.unlimited_keys', [])),
];
}
}
128 changes: 118 additions & 10 deletions src/Http/Middleware/ThrottleRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Fleetbase\Http\Middleware;

use Fleetbase\Support\ApiConsumerMetrics;
use Fleetbase\Support\ApiRateLimits;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Routing\Middleware\ThrottleRequests as ThrottleRequestsMiddleware;
use Illuminate\Support\Facades\Log;

Expand All @@ -10,10 +13,15 @@ class ThrottleRequests extends ThrottleRequestsMiddleware
/**
* Handle an incoming request.
*
* This middleware supports multiple bypass mechanisms:
* 1. Global disable via THROTTLE_ENABLED=false (for development/testing)
* Limits come from ApiRateLimits: the environment (config/api.php) supplies the
* defaults and a system administrator may override them, per organization too, from
* the console. Two bypasses remain for operators:
* 1. Global disable via THROTTLE_ENABLED=false or the admin setting
* 2. Unlimited API keys via THROTTLE_UNLIMITED_API_KEYS (for production testing)
*
* Every request is also counted per consumer (ApiConsumerMetrics) so administrators
* can see who is driving traffic and who is being throttled.
*
* @param \Illuminate\Http\Request $request
* @param int|string $maxAttempts
* @param float|int $decayMinutes
Expand All @@ -23,8 +31,10 @@ class ThrottleRequests extends ThrottleRequestsMiddleware
*/
public function handle($request, \Closure $next, $maxAttempts = null, $decayMinutes = null, $prefix = '')
{
// Check if throttling is globally disabled via configuration
if (config('api.throttle.enabled', true) === false) {
$settings = ApiRateLimits::settings();

// Check if throttling is globally disabled
if ($settings['enabled'] === false) {
// Log when throttling is disabled (for security monitoring)
if (app()->environment('production')) {
Log::warning('API throttling is DISABLED globally', [
Expand All @@ -35,7 +45,7 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu
]);
}

return $next($request);
return $this->passThrough($request, $next, $settings, null);
}

// Check if request is using an unlimited/test API key
Expand All @@ -49,14 +59,112 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu
'method' => $request->method(),
]);

return $next($request);
return $this->passThrough($request, $next, $settings, null);
}

// Organization overrides need to know whose credential this is; skip the lookup
// when there are none and consumers are not being tracked.
$consumer = ($settings['overrides'] || $settings['track_consumers']) ? $this->describeConsumer($request) : [];
$limit = ApiRateLimits::limitFor($consumer['company_uuid'] ?? null, $settings);

if ($limit === null) {
return $this->passThrough($request, $next, $settings, null, $consumer);
}

try {
$response = parent::handle($request, $next, $limit, $settings['decay_minutes'], $prefix);
} catch (ThrottleRequestsException $exception) {
$this->recordConsumer($request, $settings, $limit, $consumer, true);

throw $exception;
}

$this->recordConsumer($request, $settings, $limit, $consumer);

return $response;
}

/**
* Let a request through unthrottled, still counting it for the consumer view.
*/
protected function passThrough($request, \Closure $next, array $settings, ?int $limit, ?array $consumer = null)
{
$response = $next($request);

$this->recordConsumer($request, $settings, $limit, $consumer);

return $response;
}

/**
* Count the request against its consumer when consumer tracking is on.
*/
protected function recordConsumer($request, array $settings, ?int $limit, ?array $consumer = null, bool $throttled = false): void
{
if (!$settings['track_consumers']) {
return;
}

// Normal throttling: Get limits from configuration
$maxAttempts = config('api.throttle.max_attempts', 90);
$decayMinutes = config('api.throttle.decay_minutes', 1);
$consumer = $consumer ?: $this->describeConsumer($request);

ApiConsumerMetrics::record($this->resolveRequestSignature($request), array_merge($consumer, [
'scope' => $request->segment(1) ?? '',
'ip' => $request->ip(),
'limit' => $limit,
]), $throttled);
}

/**
* Who is making this request, for overrides and the consumer view.
*/
protected function describeConsumer($request): array
{
if ($credential = $this->extractApiKey($request)) {
return ApiRateLimits::identify($credential);
}

if ($user = $request->user()) {
return [
'type' => 'user',
'label' => $user->name ?? $user->email ?? (string) $user->getAuthIdentifier(),
'company_uuid' => $user->company_uuid ?? null,
];
}

return ['type' => 'ip', 'label' => $request->ip(), 'company_uuid' => null];
}

/**
* Resolve the limiter key from the consumer rather than the connection.
*
* This middleware runs ahead of API authentication, so Laravel's default signature
* (the authenticated user, else route domain + client IP) always fell through to the
* IP. Behind a load balancer or reverse proxy that IP is the proxy's, and no route has
* a domain, so every tenant, API key and console visitor shared one bucket: a single
* busy integration returned 429 to the entire platform.
*
* The presented credential identifies the consumer without a database lookup, so the
* key is the hashed credential. Only requests carrying no credential fall back to the
* user, then the IP. The first path segment ("v1", "int", ...) keeps the public API and
* the console's public routes in separate buckets.
*
* @param \Illuminate\Http\Request $request
*
* @return string
*/
protected function resolveRequestSignature($request)
{
$scope = 'fleetbase-throttle|' . ($request->segment(1) ?? '');

if ($credential = $this->extractApiKey($request)) {
return sha1($scope . '|credential|' . $credential);
}

if ($user = $request->user()) {
return sha1($scope . '|user|' . $user->getAuthIdentifier());
}

return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
return sha1($scope . '|ip|' . $request->ip());
}

/**
Expand Down
Loading
Loading