php-tenant/Models/WorkspaceInvitation.php
Claude dede803632
security: fix O(n) timing attack in findByToken (#9)
Add a SHA-256 token_hash lookup column to workspace_invitations so that
findByToken and findPendingByToken can locate the candidate row with a
single indexed SQL query instead of loading up to 1000 rows and running
bcrypt against each one sequentially.

The bcrypt hash in the token column is still verified after the O(1)
lookup, preserving the existing security guarantee while eliminating
both the timing side-channel and the performance bottleneck.

Changes:
- Migration to add nullable indexed token_hash column
- Model booted() creating/updating events compute SHA-256 alongside bcrypt
- findByToken/findPendingByToken rewritten to WHERE token_hash then Hash::check
- HashInvitationTokens command updated to populate token_hash for existing rows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:06:01 +00:00

241 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Models;
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class WorkspaceInvitation extends Model
{
use HasFactory;
use Notifiable;
protected static function newFactory(): WorkspaceInvitationFactory
{
return WorkspaceInvitationFactory::new();
}
protected $fillable = [
'workspace_id',
'email',
'token',
'token_hash',
'role',
'invited_by',
'expires_at',
'accepted_at',
];
protected $casts = [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
];
/**
* The "booted" method of the model.
*
* 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
{
static::creating(function (WorkspaceInvitation $invitation) {
// 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);
}
});
}
/**
* Get the workspace this invitation is for.
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the user who sent this invitation.
*/
public function inviter(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
/**
* Scope to pending invitations (not accepted, not expired).
*/
public function scopePending($query)
{
return $query->whereNull('accepted_at')
->where('expires_at', '>', now());
}
/**
* Scope to expired invitations.
*/
public function scopeExpired($query)
{
return $query->whereNull('accepted_at')
->where('expires_at', '<=', now());
}
/**
* Scope to accepted invitations.
*/
public function scopeAccepted($query)
{
return $query->whereNotNull('accepted_at');
}
/**
* Check if invitation is pending (not accepted and not expired).
*/
public function isPending(): bool
{
return $this->accepted_at === null && $this->expires_at->isFuture();
}
/**
* Check if invitation has expired.
*/
public function isExpired(): bool
{
return $this->accepted_at === null && $this->expires_at->isPast();
}
/**
* Check if invitation has been accepted.
*/
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
/**
* Generate a unique token for this invitation.
*
* Returns the plaintext token. The token will be hashed when stored.
*/
public static function generateToken(): string
{
// Generate a cryptographically secure random token
// No need to check for uniqueness since hashed tokens are unique
return Str::random(64);
}
/**
* Find invitation by token.
*
* 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
{
$tokenHash = hash('sha256', $token);
$invitation = static::where('token_hash', $tokenHash)->first();
if (! $invitation) {
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.
*
* 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
{
$tokenHash = hash('sha256', $token);
$invitation = static::pending()
->where('token_hash', $tokenHash)
->first();
if (! $invitation) {
return null;
}
// Verify the plaintext against the bcrypt hash as a second factor
if (! Hash::check($token, $invitation->token)) {
return null;
}
return $invitation;
}
/**
* Verify if the given plaintext token matches this invitation's hashed token.
*/
public function verifyToken(string $plaintextToken): bool
{
return Hash::check($plaintextToken, $this->token);
}
/**
* Accept the invitation for a user.
*/
public function accept(User $user): bool
{
if (! $this->isPending()) {
return false;
}
// Check if user already belongs to this workspace
if ($this->workspace->users()->where('user_id', $user->id)->exists()) {
// Mark as accepted but don't add again
$this->update(['accepted_at' => now()]);
return true;
}
// Add user to workspace with the invited role
$this->workspace->users()->attach($user->id, [
'role' => $this->role,
'is_default' => false,
]);
// Mark invitation as accepted
$this->update(['accepted_at' => now()]);
return true;
}
/**
* Get the notification routing for mail.
*/
public function routeNotificationForMail(): string
{
return $this->email;
}
}