Compare commits
1 commit
dev
...
feat/fix-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dede803632 |
3 changed files with 85 additions and 23 deletions
|
|
@ -125,10 +125,14 @@ class HashInvitationTokens extends Command
|
|||
foreach ($toMigrate as $record) {
|
||||
try {
|
||||
$hashedToken = Hash::make($record->token);
|
||||
$tokenHash = hash('sha256', $record->token);
|
||||
|
||||
DB::table('workspace_invitations')
|
||||
->where('id', $record->id)
|
||||
->update(['token' => $hashedToken]);
|
||||
->update([
|
||||
'token' => $hashedToken,
|
||||
'token_hash' => $tokenHash,
|
||||
]);
|
||||
|
||||
$migrated++;
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Add a SHA-256 lookup hash column to workspace_invitations.
|
||||
*
|
||||
* This enables O(1) token lookup via SQL instead of the previous O(n)
|
||||
* sequential bcrypt check, eliminating a timing attack surface and
|
||||
* improving performance.
|
||||
*
|
||||
* The token_hash column stores hash('sha256', $plaintext) and is used
|
||||
* for fast candidate lookup. The existing bcrypt token column is still
|
||||
* used for final verification via Hash::check().
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workspace_invitations', function (Blueprint $table) {
|
||||
$table->string('token_hash', 64)->nullable()->after('token');
|
||||
$table->index('token_hash');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workspace_invitations', function (Blueprint $table) {
|
||||
$table->dropIndex(['token_hash']);
|
||||
$table->dropColumn('token_hash');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -26,6 +26,7 @@ class WorkspaceInvitation extends Model
|
|||
'workspace_id',
|
||||
'email',
|
||||
'token',
|
||||
'token_hash',
|
||||
'role',
|
||||
'invited_by',
|
||||
'expires_at',
|
||||
|
|
@ -40,7 +41,9 @@ class WorkspaceInvitation extends Model
|
|||
/**
|
||||
* The "booted" method of the model.
|
||||
*
|
||||
* Automatically hashes tokens when creating invitations.
|
||||
* Automatically hashes tokens when creating or updating invitations.
|
||||
* Stores both a bcrypt hash (for verification) and a SHA-256 hash
|
||||
* (for O(1) lookup) to avoid timing attacks and O(n) scans.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
|
|
@ -48,6 +51,15 @@ class WorkspaceInvitation extends Model
|
|||
// Only hash if the token looks like a plaintext token (not already hashed)
|
||||
// Bcrypt hashes start with $2y$ and are 60 chars
|
||||
if ($invitation->token && ! str_starts_with($invitation->token, '$2y$')) {
|
||||
$invitation->token_hash = hash('sha256', $invitation->token);
|
||||
$invitation->token = Hash::make($invitation->token);
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function (WorkspaceInvitation $invitation) {
|
||||
// If the token is being changed and is plaintext, rehash both columns
|
||||
if ($invitation->isDirty('token') && $invitation->token && ! str_starts_with($invitation->token, '$2y$')) {
|
||||
$invitation->token_hash = hash('sha256', $invitation->token);
|
||||
$invitation->token = Hash::make($invitation->token);
|
||||
}
|
||||
});
|
||||
|
|
@ -134,44 +146,52 @@ class WorkspaceInvitation extends Model
|
|||
/**
|
||||
* Find invitation by token.
|
||||
*
|
||||
* Since tokens are hashed, we must check each pending/valid invitation
|
||||
* against the provided plaintext token using Hash::check().
|
||||
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
||||
* then verifies the plaintext against the bcrypt hash. This eliminates
|
||||
* the previous O(n) sequential scan and timing attack surface.
|
||||
*/
|
||||
public static function findByToken(string $token): ?self
|
||||
{
|
||||
// Get all invitations and check the hash
|
||||
// We limit to recent invitations to improve performance
|
||||
$invitations = static::orderByDesc('created_at')
|
||||
->limit(1000)
|
||||
->get();
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
foreach ($invitations as $invitation) {
|
||||
if (Hash::check($token, $invitation->token)) {
|
||||
return $invitation;
|
||||
}
|
||||
$invitation = static::where('token_hash', $tokenHash)->first();
|
||||
|
||||
if (! $invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Verify the plaintext against the bcrypt hash as a second factor
|
||||
if (! Hash::check($token, $invitation->token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pending invitation by token.
|
||||
*
|
||||
* Since tokens are hashed, we must check each pending invitation
|
||||
* against the provided plaintext token using Hash::check().
|
||||
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
||||
* then verifies the plaintext against the bcrypt hash.
|
||||
*/
|
||||
public static function findPendingByToken(string $token): ?self
|
||||
{
|
||||
// Get pending invitations and check the hash
|
||||
$invitations = static::pending()->get();
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
foreach ($invitations as $invitation) {
|
||||
if (Hash::check($token, $invitation->token)) {
|
||||
return $invitation;
|
||||
}
|
||||
$invitation = static::pending()
|
||||
->where('token_hash', $tokenHash)
|
||||
->first();
|
||||
|
||||
if (! $invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Verify the plaintext against the bcrypt hash as a second factor
|
||||
if (! Hash::check($token, $invitation->token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue