Compare commits
No commits in common. "feat/fix-token-timing-attack" and "dev" have entirely different histories.
feat/fix-t
...
dev
3 changed files with 23 additions and 85 deletions
|
|
@ -125,14 +125,10 @@ 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([
|
->update(['token' => $hashedToken]);
|
||||||
'token' => $hashedToken,
|
|
||||||
'token_hash' => $tokenHash,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$migrated++;
|
$migrated++;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?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,7 +26,6 @@ class WorkspaceInvitation extends Model
|
||||||
'workspace_id',
|
'workspace_id',
|
||||||
'email',
|
'email',
|
||||||
'token',
|
'token',
|
||||||
'token_hash',
|
|
||||||
'role',
|
'role',
|
||||||
'invited_by',
|
'invited_by',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
|
|
@ -41,9 +40,7 @@ class WorkspaceInvitation extends Model
|
||||||
/**
|
/**
|
||||||
* The "booted" method of the model.
|
* The "booted" method of the model.
|
||||||
*
|
*
|
||||||
* Automatically hashes tokens when creating or updating invitations.
|
* Automatically hashes tokens when creating 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
|
||||||
{
|
{
|
||||||
|
|
@ -51,15 +48,6 @@ 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -146,52 +134,44 @@ class WorkspaceInvitation extends Model
|
||||||
/**
|
/**
|
||||||
* Find invitation by token.
|
* Find invitation by token.
|
||||||
*
|
*
|
||||||
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
* Since tokens are hashed, we must check each pending/valid invitation
|
||||||
* then verifies the plaintext against the bcrypt hash. This eliminates
|
* against the provided plaintext token using Hash::check().
|
||||||
* the previous O(n) sequential scan and timing attack surface.
|
|
||||||
*/
|
*/
|
||||||
public static function findByToken(string $token): ?self
|
public static function findByToken(string $token): ?self
|
||||||
{
|
{
|
||||||
$tokenHash = hash('sha256', $token);
|
// Get all invitations and check the hash
|
||||||
|
// We limit to recent invitations to improve performance
|
||||||
|
$invitations = static::orderByDesc('created_at')
|
||||||
|
->limit(1000)
|
||||||
|
->get();
|
||||||
|
|
||||||
$invitation = static::where('token_hash', $tokenHash)->first();
|
foreach ($invitations as $invitation) {
|
||||||
|
if (Hash::check($token, $invitation->token)) {
|
||||||
if (! $invitation) {
|
return $invitation;
|
||||||
return null;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the plaintext against the bcrypt hash as a second factor
|
return null;
|
||||||
if (! Hash::check($token, $invitation->token)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $invitation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find pending invitation by token.
|
* Find pending invitation by token.
|
||||||
*
|
*
|
||||||
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
|
* Since tokens are hashed, we must check each pending invitation
|
||||||
* then verifies the plaintext against the bcrypt hash.
|
* against the provided plaintext token using Hash::check().
|
||||||
*/
|
*/
|
||||||
public static function findPendingByToken(string $token): ?self
|
public static function findPendingByToken(string $token): ?self
|
||||||
{
|
{
|
||||||
$tokenHash = hash('sha256', $token);
|
// Get pending invitations and check the hash
|
||||||
|
$invitations = static::pending()->get();
|
||||||
|
|
||||||
$invitation = static::pending()
|
foreach ($invitations as $invitation) {
|
||||||
->where('token_hash', $tokenHash)
|
if (Hash::check($token, $invitation->token)) {
|
||||||
->first();
|
return $invitation;
|
||||||
|
}
|
||||||
if (! $invitation) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the plaintext against the bcrypt hash as a second factor
|
return null;
|
||||||
if (! Hash::check($token, $invitation->token)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $invitation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue