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) {
|
foreach ($toMigrate as $record) {
|
||||||
try {
|
try {
|
||||||
$hashedToken = Hash::make($record->token);
|
$hashedToken = Hash::make($record->token);
|
||||||
|
$tokenHash = hash('sha256', $record->token);
|
||||||
|
|
||||||
DB::table('workspace_invitations')
|
DB::table('workspace_invitations')
|
||||||
->where('id', $record->id)
|
->where('id', $record->id)
|
||||||
->update(['token' => $hashedToken]);
|
->update([
|
||||||
|
'token' => $hashedToken,
|
||||||
|
'token_hash' => $tokenHash,
|
||||||
|
]);
|
||||||
|
|
||||||
$migrated++;
|
$migrated++;
|
||||||
} catch (\Throwable $e) {
|
} 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',
|
'workspace_id',
|
||||||
'email',
|
'email',
|
||||||
'token',
|
'token',
|
||||||
|
'token_hash',
|
||||||
'role',
|
'role',
|
||||||
'invited_by',
|
'invited_by',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
|
|
@ -40,7 +41,9 @@ class WorkspaceInvitation extends Model
|
||||||
/**
|
/**
|
||||||
* The "booted" method of the 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
|
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)
|
// Only hash if the token looks like a plaintext token (not already hashed)
|
||||||
// Bcrypt hashes start with $2y$ and are 60 chars
|
// Bcrypt hashes start with $2y$ and are 60 chars
|
||||||
if ($invitation->token && ! str_starts_with($invitation->token, '$2y$')) {
|
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);
|
$invitation->token = Hash::make($invitation->token);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -134,44 +146,52 @@ class WorkspaceInvitation extends Model
|
||||||
/**
|
/**
|
||||||
* Find invitation by token.
|
* Find invitation by token.
|
||||||
*
|
*
|
||||||
* Since tokens are hashed, we must check each pending/valid invitation
|
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
||||||
* against the provided plaintext token using Hash::check().
|
* 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
|
public static function findByToken(string $token): ?self
|
||||||
{
|
{
|
||||||
// Get all invitations and check the hash
|
$tokenHash = hash('sha256', $token);
|
||||||
// We limit to recent invitations to improve performance
|
|
||||||
$invitations = static::orderByDesc('created_at')
|
|
||||||
->limit(1000)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($invitations as $invitation) {
|
$invitation = static::where('token_hash', $tokenHash)->first();
|
||||||
if (Hash::check($token, $invitation->token)) {
|
|
||||||
return $invitation;
|
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.
|
* Find pending invitation by token.
|
||||||
*
|
*
|
||||||
* Since tokens are hashed, we must check each pending invitation
|
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
||||||
* against the provided plaintext token using Hash::check().
|
* then verifies the plaintext against the bcrypt hash.
|
||||||
*/
|
*/
|
||||||
public static function findPendingByToken(string $token): ?self
|
public static function findPendingByToken(string $token): ?self
|
||||||
{
|
{
|
||||||
// Get pending invitations and check the hash
|
$tokenHash = hash('sha256', $token);
|
||||||
$invitations = static::pending()->get();
|
|
||||||
|
|
||||||
foreach ($invitations as $invitation) {
|
$invitation = static::pending()
|
||||||
if (Hash::check($token, $invitation->token)) {
|
->where('token_hash', $tokenHash)
|
||||||
return $invitation;
|
->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