Feat: Adding hashes for otp codes
This commit is contained in:
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
|
||||
* at all — firstOrCreate() and UserCreated only fire from validate(), and
|
||||
* only once the code has actually been proven correct. An email that
|
||||
* already has a User row is unaffected: its OTP state still lives on that
|
||||
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
|
||||
* before, so a returning shopper's login is unchanged.
|
||||
* row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
|
||||
* before, so a returning shopper's login is unchanged. otp_code_hash
|
||||
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
|
||||
* on App\Models\User hashes it automatically on assignment, same as
|
||||
* password), not the code itself — compared via Hash::check().
|
||||
*
|
||||
* Two independent throttles, both configured under core.auth.otp — see
|
||||
* config/core.php's own comment for why they're separate: max_attempts
|
||||
@@ -87,7 +91,7 @@ class UserOtpService
|
||||
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($user) {
|
||||
$user->otp_code = $code;
|
||||
$user->otp_code_hash = $code;
|
||||
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
|
||||
$user->otp_attempts = 0;
|
||||
$user->save();
|
||||
@@ -96,8 +100,12 @@ class UserOtpService
|
||||
// class's own docblock for why: creating one on every
|
||||
// generateAndSend() call let anyone mint real User/Customer
|
||||
// rows for an email nobody proved they owned.
|
||||
//
|
||||
// Hashed even in the cache (not just on the DB-backed path)
|
||||
// — a code sitting in Cache::get()-able storage is the same
|
||||
// exposure as a plaintext DB column if anything can read it.
|
||||
Cache::put($this->pendingKey($email), [
|
||||
'code' => $code,
|
||||
'code_hash' => Hash::make($code),
|
||||
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
|
||||
'attempts' => 0,
|
||||
], now()->addMinutes(self::EXPIRY_MINUTES));
|
||||
@@ -154,15 +162,15 @@ class UserOtpService
|
||||
return DB::transaction(function () use ($model, $email, $code) {
|
||||
$user = $model::where('email', $email)->lockForUpdate()->first();
|
||||
|
||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||
if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $user->otp_code, $code)) {
|
||||
if (! Hash::check($code, $user->otp_code_hash)) {
|
||||
$user->otp_attempts++;
|
||||
|
||||
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
|
||||
$user->otp_code = null;
|
||||
$user->otp_code_hash = null;
|
||||
$user->otp_expires_at = null;
|
||||
$user->otp_attempts = 0;
|
||||
}
|
||||
@@ -172,7 +180,7 @@ class UserOtpService
|
||||
return null;
|
||||
}
|
||||
|
||||
$user->otp_code = null;
|
||||
$user->otp_code_hash = null;
|
||||
$user->otp_expires_at = null;
|
||||
$user->otp_attempts = 0;
|
||||
$user->save();
|
||||
@@ -200,7 +208,7 @@ class UserOtpService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $pending['code'], $code)) {
|
||||
if (! Hash::check($code, $pending['code_hash'])) {
|
||||
$pending['attempts']++;
|
||||
|
||||
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
|
||||
|
||||
Reference in New Issue
Block a user