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
79 changes: 63 additions & 16 deletions server/src/Http/Controllers/Api/v1/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class CustomerController extends Controller

public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.';

/**
* Wrong guesses a single code tolerates before it locks itself (core's check()).
*/
public const CODE_CHECK_ATTEMPTS = 5;

/* ============================================================
| Public auth flows (API credential only, no Customer-Token)
* ============================================================ */
Expand Down Expand Up @@ -80,23 +85,21 @@ public function requestCreationCode(VerifyCreateCustomerRequest $request)
$providedName = trim((string) $request->input('name', ''));
$providedPhone = $request->filled('phone') ? static::phone($request->input('phone')) : null;

// The verification code needs a persisted subject so the mail renderer
// can resolve the polymorphic `subject` relation (the verification blade
// template references `$user->name`). Look up — or create — the User
// before sending. `create()` later backfills password + remaining fields
// on this same row when the customer confirms the code.
// The code's subject is the person signing up: an existing user (a prior
// incomplete signup, or an account in another organization) as it stands, else
// an unsaved User carrying the name and contact details the message needs.
// Nothing is persisted until create() verifies the code — a users row per
// unverified identity was a way to fill the table from a leaked credential.
$subject = $this->findActiveUserByIdentity($identity, $isEmail ? 'email' : 'phone');

if (!$subject) {
// `password` and `type` are guarded on User; assign type after create
// via setUserType (saves the row).
$subject = $this->createUser([
$subject = new User();
$subject->forceFill([
'company_uuid' => $sessionCompany,
'name' => $providedName !== '' ? $providedName : $identity,
'email' => $isEmail ? $identity : null,
'phone' => $isEmail ? $providedPhone : $identity,
]);
$subject->setUserType('customer');
} elseif ($providedName !== '' && (!$subject->name || $subject->name === $subject->email)) {
// Existing stub user from a prior incomplete signup — refresh the
// greeting name if the client supplied one.
Expand All @@ -118,6 +121,7 @@ public function requestCreationCode(VerifyCreateCustomerRequest $request)
]);
} else {
$this->generateSmsVerification($subject, 'fleetops_create_customer', [
'hashed' => true,
'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
'meta' => $meta,
]);
Expand Down Expand Up @@ -393,6 +397,7 @@ public function loginWithPhone(Request $request)

try {
$this->generateSmsVerification($user, 'fleetops_customer_login', [
'hashed' => true,
'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
]);

Expand Down Expand Up @@ -494,6 +499,7 @@ public function forgotPassword(Request $request)
]);
} else {
$this->generateSmsVerification($user, 'fleetops_customer_password_reset', [
'hashed' => true,
'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
'meta' => $meta,
]);
Expand Down Expand Up @@ -542,12 +548,9 @@ public function resetPassword(Request $request)
// setPasswordAttribute mutator hashes plaintext; don't pre-hash here.
$user->password = $password;
$user->save();
// Invalidate all existing sessions for this user after a password reset.
// Invalidate all existing sessions for this user after a password reset. The
// code itself was used up when it verified (findVerificationCode).
$this->deleteUserTokens($user);
// Null on the testing-bypass path — there is no row to consume.
if ($verificationCode) {
$verificationCode->delete();
}

return response()->json(['status' => 'ok']);
}
Expand Down Expand Up @@ -978,14 +981,58 @@ protected function verificationBypassMatches(?string $identity, ?string $code):
);
}

/**
* Whether the submitted code verifies against a live code for the purpose and
* subject or identity in `$attributes`, using the code up when it does.
*/
protected function verificationCodeExists(array $attributes): bool
{
return VerificationCode::where($attributes)->exists();
return $this->findVerificationCode($attributes) !== null;
}

/**
* The live code the submitted code verified against, used up so it cannot verify
* again, or null. `$attributes` carries `code`, `for` and either `subject_uuid` or
* `meta->identity`. Live codes are checked newest first; a wrong guess counts against
* each until it locks (core's check()). Plaintext rows written before codes were hashed
* verify the same way.
*/
protected function findVerificationCode(array $attributes): mixed
{
return VerificationCode::where($attributes)->first();
$code = (string) ($attributes['code'] ?? '');

foreach ($this->liveVerificationCodes($attributes) as $verificationCode) {
if ($verificationCode->check($code, static::CODE_CHECK_ATTEMPTS) === VerificationCode::CHECK_VALID) {
$verificationCode->consume();

return $verificationCode;
}
}

return null;
}

/**
* Unexpired, unused, unlocked codes for the purpose and subject or identity in
* `$attributes`, newest first.
*
* @return \Illuminate\Support\Collection<int, VerificationCode>
*/
protected function liveVerificationCodes(array $attributes)
{
$query = VerificationCode::query()
->where('for', (string) ($attributes['for'] ?? ''))
->where(function ($query) {
$query->whereNull('status')->orWhereNotIn('status', [VerificationCode::STATUS_USED, VerificationCode::STATUS_LOCKED]);
});

foreach (['subject_uuid', 'meta->identity'] as $column) {
if (array_key_exists($column, $attributes)) {
$query->where($column, $attributes[$column]);
}
}

return $query->orderByDesc('created_at')->get();
}

protected function findFileByPublicId(string $publicId): mixed
Expand Down
51 changes: 42 additions & 9 deletions server/src/Http/Controllers/Api/v1/DriverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class DriverController extends Controller
*/
public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.';

/**
* Wrong guesses a single code tolerates before it locks itself (core's check()).
*/
public const CODE_CHECK_ATTEMPTS = 5;

/**
* Public expansion name => Eloquent relation name.
*
Expand Down Expand Up @@ -588,6 +593,7 @@ public function loginWithPhone()
// generate verification token
try {
VerificationCode::generateSmsVerificationFor($user, 'driver_login', [
'hashed' => true,
'company_uuid' => $company->uuid,
'messageCallback' => function ($verification) use ($company) {
return 'Your ' . data_get($company, 'name', config('app.name')) . ' verification code is ' . $verification->code;
Expand Down Expand Up @@ -652,8 +658,8 @@ public function verifyCode(Request $request)
return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
}

// find and verify code
$verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
// find and verify code: the user's newest live code for this purpose, used up
$verificationCode = static::spendCode($user, $for, (string) $code);
if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
return response()->apiError('Invalid verification code!');
}
Expand Down Expand Up @@ -1387,7 +1393,7 @@ public function resetPassword(Request $request)
$user->password = $password;
$user->save();

$verification->delete();
// The code was used up when it verified (findResetCode); every session ends.
$user->tokens()->delete();

return response()->json(['status' => 'ok']);
Expand Down Expand Up @@ -1422,14 +1428,40 @@ protected static function passwordMatches(User $user, string $plain): bool
return Hash::check($plain, $user->password);
}

/** The unexpired reset code for this user, if the one supplied matches. */
/**
* The live reset code the supplied one verified against, used up so it cannot
* verify again, or null.
*/
protected static function findResetCode(User $user, string $code)
{
return VerificationCode::where([
'subject_uuid' => $user->uuid,
'code' => $code,
'for' => 'driver_password_reset',
])->where('expires_at', '>', now())->first();
return static::spendCode($user, 'driver_password_reset', $code);
}

/**
* Verify a code against the user's live codes for a purpose and use it up. Codes are
* checked newest first; a wrong guess counts against each until it locks (core's
* check()). Plaintext rows written before codes were hashed verify the same way.
*/
protected static function spendCode(User $user, string $for, string $code): ?VerificationCode
{
$live = VerificationCode::query()
->where('for', $for)
->where('subject_uuid', $user->uuid)
->where(function ($query) {
$query->whereNull('status')->orWhereNotIn('status', [VerificationCode::STATUS_USED, VerificationCode::STATUS_LOCKED]);
})
->orderByDesc('created_at')
->get();

foreach ($live as $verificationCode) {
if ($verificationCode->check($code, static::CODE_CHECK_ATTEMPTS) === VerificationCode::CHECK_VALID) {
$verificationCode->consume();

return $verificationCode;
}
}

return null;
}

/** Sends the reset code by whichever channel the identity names. */
Expand All @@ -1444,6 +1476,7 @@ protected static function sendResetCode(User $user, string $identity): void
]);
} else {
VerificationCode::generateSmsVerificationFor($user, 'driver_password_reset', [
'hashed' => true,
'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
]);
}
Expand Down
20 changes: 16 additions & 4 deletions server/src/Support/TrackingPage/TrackingAccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public function checkCode(TrackingScope $scope, string $code): array
$result = $verificationCode->check($code, self::MAX_ATTEMPTS);
if ($result === VerificationCode::CHECK_VALID) {
$channel = $verificationCode->getMeta('channel');
$this->deleteCode($verificationCode);
$this->consumeCode($verificationCode);

return ['status' => $result, 'channel' => $channel];
}
Expand Down Expand Up @@ -290,14 +290,26 @@ protected function createCode(mixed $recipient, array $options): VerificationCod
return VerificationCode::issue($recipient, self::CODE_PURPOSE, $options);
}

/**
* The scope's newest code that has not been used. Read without the expiry scope so an
* expired code is reported as such rather than as missing; a used code stays on the
* table for the ledger but never verifies again.
*/
protected function latestCode(TrackingScope $scope): ?VerificationCode
{
return VerificationCode::withoutGlobalScope(ExpiryScope::class)->where('for', self::CODE_PURPOSE)->where('meta->scope', $scope->key())->latest()->first();
return VerificationCode::withoutGlobalScope(ExpiryScope::class)
->where('for', self::CODE_PURPOSE)
->where('meta->scope', $scope->key())
->where(function ($query) {
$query->whereNull('status')->orWhere('status', '!=', VerificationCode::STATUS_USED);
})
->latest()
->first();
}

protected function deleteCode(VerificationCode $verificationCode): void
protected function consumeCode(VerificationCode $verificationCode): void
{
$verificationCode->delete();
$verificationCode->consume();
}

protected function findSession(string $tokenHash): ?TrackingSession
Expand Down
10 changes: 3 additions & 7 deletions server/tests/ApiCustomerControllerContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -692,12 +692,9 @@ function fleetopsApiCustomerJson($response): array
]));

expect($created)->toBe(['status' => 'ok'])
->and($controller->createdUsers[0])->toMatchArray([
'company_uuid' => 'company-uuid',
'name' => 'Jane',
'email' => 'jane@example.test',
'phone' => '+15551234567',
])
// Nothing is persisted for an unverified identity: the code's subject is an unsaved user.
->and($controller->createdUsers)->toBe([])
->and($controller->emailVerifications[0][0])->toBeNull()
->and($controller->emailVerifications[0][1])->toBe('fleetops_create_customer')
->and($updated)->toBe(['status' => 'ok'])
->and($stub->name)->toBe('Stub Name')
Expand Down Expand Up @@ -844,7 +841,6 @@ function fleetopsApiCustomerJson($response): array
->and(fleetopsApiCustomerJson($reset))->toBe(['status' => 'ok'])
->and($controller->genericUser->savedForTest)->toBeTrue()
->and($controller->genericUser->tokensDeleted)->toBeTrue()
->and($controller->verificationCode->deleted)->toBeTrue()
->and(fleetopsApiCustomerJson($unknown->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [
'identity' => 'missing@example.test',
]))))->toBe(['status' => 'ok'])
Expand Down
19 changes: 4 additions & 15 deletions server/tests/ApiDriverPasswordContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,31 +362,20 @@ function fleetopsPasswordUser(string $plainPassword = 'correct-horse'): FleetOps
->and($unknownIdentity->getData())->toBe($badCode->getData());
});

test('reset password sets the password, spends the code, and ends every session', function () {
test('reset password sets the password and ends every session once the code verified', function () {
$user = fleetopsPasswordUser();
FleetOpsPasswordControllerProbe::$identityUser = $user;

$deleted = false;
FleetOpsPasswordControllerProbe::$resetCode = new class($deleted) {
public function __construct(public bool &$deleted)
{
}

public function delete(): bool
{
$this->deleted = true;

return true;
}
};
// findResetCode() hands back the code it verified and used up; nothing more is done
// to it here.
FleetOpsPasswordControllerProbe::$resetCode = (object) ['uuid' => 'reset-code'];

$response = (new FleetOpsPasswordControllerProbe())->resetPassword(
new Request(['identity' => 'driver@example.test', 'code' => '123456', 'password' => 'newpassword'])
);

expect($response->getStatusCode())->toBe(200)
->and($user->saved)->toHaveCount(1)
->and($deleted)->toBeTrue()
// A reset is a recovery from losing control; nothing keeps working.
->and($user->tokensDeleted)->toBeTrue();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,3 +829,27 @@ protected function createFileFromBase64(string $contents, string $path): mixed
expect($connection->table('contacts')->where('uuid', '88888888-8888-4888-8888-888888888820')->value('photo_uuid'))->toBe('file-b64-1');
app()->forgetInstance(CustomerAuth::APP_BINDING);
});

test('a wrong code counts against the live code, a right one uses it up, and a used code never verifies again', function () {
$connection = fleetopsCustomerHelperBoot();
$controller = new CustomerController();

$connection->table('verification_codes')->insert([
['uuid' => 'vc-older', 'code' => '101010', 'for' => 'fleetops_create_customer', 'meta' => json_encode(['identity' => 'spend@example.com']), 'status' => 'active', 'created_at' => '2026-10-13 09:00:00'],
['uuid' => 'vc-newer', 'code' => '202020', 'for' => 'fleetops_create_customer', 'meta' => json_encode(['identity' => 'spend@example.com']), 'status' => 'active', 'created_at' => '2026-10-13 09:05:00'],
]);

$wrong = $controller->create(CreateCustomerRequest::create('/v1/customers', 'POST', ['code' => '999999', 'identity' => 'spend@example.com', 'name' => 'Spender', 'password' => 'secret']));
expect($wrong->getData(true)['error'])->toContain('Invalid verification code')
->and(json_decode($connection->table('verification_codes')->where('uuid', 'vc-newer')->value('meta'), true)['attempts'])->toBe(1)
->and(json_decode($connection->table('verification_codes')->where('uuid', 'vc-older')->value('meta'), true)['attempts'])->toBe(1);

// The older code still verifies while it is live, and is used up by doing so.
$controller->create(CreateCustomerRequest::create('/v1/customers', 'POST', ['code' => '101010', 'identity' => 'spend@example.com', 'name' => 'Spender', 'password' => 'secret']));
expect($connection->table('contacts')->where('email', 'spend@example.com')->count())->toBe(1)
->and($connection->table('verification_codes')->where('uuid', 'vc-older')->value('status'))->toBe('used')
->and($connection->table('verification_codes')->where('uuid', 'vc-newer')->value('status'))->toBe('active');

$again = $controller->create(CreateCustomerRequest::create('/v1/customers', 'POST', ['code' => '101010', 'identity' => 'spend@example.com', 'name' => 'Spender', 'password' => 'secret']));
expect($again->getData(true)['error'])->toContain('Invalid verification code');
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,10 @@ function trackingRequest(array $cookies = [], string $ip = '203.0.113.7'): Reque
expect($access->checkCode($scope, $wrong))->toBe(['status' => VerificationCode::CHECK_INVALID, 'attempts_left' => 2])
->and($access->pausedFor($scope))->toBe(0);

// A used code stays on the table for the ledger, marked used, and never verifies again.
expect($access->checkCode($scope, $second->plainCode))->toBe(['status' => VerificationCode::CHECK_VALID, 'channel' => 'email'])
->and(VerificationCode::withoutGlobalScope(ExpiryScope::class)->count())->toBe(0)
->and(VerificationCode::withoutGlobalScope(ExpiryScope::class)->count())->toBe(1)
->and(VerificationCode::withoutGlobalScope(ExpiryScope::class)->value('status'))->toBe(VerificationCode::STATUS_USED)
->and($access->checkCode($scope, $second->plainCode))->toBe(['status' => VerificationCode::CHECK_EXPIRED]);

$third = $access->issueCode($scope, null, 'sms');
Expand Down