Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc6f5e5f0 | ||
|
|
0babc6a96d |
+11
-1
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.18.0] - 2026-09-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -37,6 +37,16 @@ call now exist:
|
|||||||
client-supplied id alone (verified live: a second customer attempting to read/edit the first's
|
client-supplied id alone (verified live: a second customer attempting to read/edit the first's
|
||||||
address or order gets `AddressNotFoundException`/`OrderNotFoundException`, not the record).
|
address or order gets `AddressNotFoundException`/`OrderNotFoundException`, not the record).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Modules\Core\Auth\Services\UserOtpService::validate()`'s wrong-guess counter (`otp_attempts`)
|
||||||
|
was read-check-increment-saved with no locking — two guesses fired in parallel for the same
|
||||||
|
user could each read the same pre-increment value and both save past `max_attempts`, letting an
|
||||||
|
attacker exceed the 5-guess lockout by parallelizing requests instead of sending them serially.
|
||||||
|
Now wrapped in a `DB::transaction()` with `lockForUpdate()` on the user row, so concurrent
|
||||||
|
guesses serialize correctly against the shared counter.
|
||||||
|
- The OTP code comparison used a plain `!=` rather than a timing-safe comparison. Now
|
||||||
|
`hash_equals()`.
|
||||||
|
|
||||||
## [0.17.5] - 2026-09-15
|
## [0.17.5] - 2026-09-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.17.5",
|
"version": "0.18.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Modules\Core\Auth\Services;
|
|||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -97,40 +98,55 @@ class UserOtpService
|
|||||||
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
|
public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable
|
||||||
{
|
{
|
||||||
$model = config('auth.providers.users.model');
|
$model = config('auth.providers.users.model');
|
||||||
$user = $model::where('email', $email)->first();
|
|
||||||
|
|
||||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
// lockForUpdate() + a transaction make the read-check-increment-save
|
||||||
return null;
|
// below atomic across concurrent requests for the same user — without
|
||||||
}
|
// it, two guesses fired in parallel can each read the same
|
||||||
|
// pre-increment otp_attempts value and both save past
|
||||||
|
// max_attempts, letting an attacker exceed the lockout by
|
||||||
|
// parallelizing requests instead of sending them serially.
|
||||||
|
$result = DB::transaction(function () use ($model, $email, $code) {
|
||||||
|
$user = $model::where('email', $email)->lockForUpdate()->first();
|
||||||
|
|
||||||
if ($user->otp_code != $code) {
|
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||||
$user->otp_attempts++;
|
return null;
|
||||||
|
|
||||||
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
|
|
||||||
$user->otp_code = null;
|
|
||||||
$user->otp_expires_at = null;
|
|
||||||
$user->otp_attempts = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! hash_equals((string) $user->otp_code, $code)) {
|
||||||
|
$user->otp_attempts++;
|
||||||
|
|
||||||
|
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
|
||||||
|
$user->otp_code = null;
|
||||||
|
$user->otp_expires_at = null;
|
||||||
|
$user->otp_attempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->otp_code = null;
|
||||||
|
$user->otp_expires_at = null;
|
||||||
|
$user->otp_attempts = 0;
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! $result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->otp_code = null;
|
|
||||||
$user->otp_expires_at = null;
|
|
||||||
$user->otp_attempts = 0;
|
|
||||||
$user->save();
|
|
||||||
|
|
||||||
RateLimiter::clear($this->generationLimiterKey($email));
|
RateLimiter::clear($this->generationLimiterKey($email));
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($result);
|
||||||
|
|
||||||
$this->sessions->record($user, $request);
|
$this->sessions->record($result, $request);
|
||||||
|
|
||||||
Event::dispatch(new CustomerLoggedIn($user));
|
Event::dispatch(new CustomerLoggedIn($result));
|
||||||
|
|
||||||
return $user;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generationLimiterKey(string $email): string
|
private function generationLimiterKey(string $email): string
|
||||||
|
|||||||
Reference in New Issue
Block a user