From 52f6380ac00961fd5ec34c8575240c66265ecb45 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 14 Apr 2026 06:52:54 +0200 Subject: [PATCH] =?UTF-8?q?security:=20round=203=20=E2=80=94=20token=20sec?= =?UTF-8?q?urity=20(crypto=20random,=20hashed=20storage,=20portal=20middle?= =?UTF-8?q?ware)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token generation: - Replace Str::ulid() with bin2hex(random_bytes(32)) for 256-bit entropy - Store SHA-256 hash in database, never plaintext tokens - Hash input before lookup on all token endpoints Invitation tokens: - InvitationService: generate crypto random, store hash, pass plain token transiently for email URL via UserInvitation::$plainToken - InvitationController show/accept: hash input before DB lookup - AcceptInvitationRequest: hash token before invitation lookup - Migration: widen user_invitations.token and artists.portal_token from char(26) to char(64) for SHA-256 hex digests Portal token auth: - PortalTokenController: remove Schema::hasTable() runtime checks, hash token before lookup, return shaped response via PortalEventResource instead of raw model data - Create PortalEventResource (name, dates, status only — no internals) - Handle missing production_requests table gracefully via try/catch Portal token middleware: - Implement full token validation: extract from Bearer header or ?token= query param, hash, look up in artists/production_requests, verify event exists and is not draft/closed, set portal context on request - Return generic 401 on any failure (no information leakage) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Api/V1/InvitationController.php | 7 +- .../Api/V1/PortalTokenController.php | 62 ++++++++++++++ .../Http/Middleware/PortalTokenMiddleware.php | 81 +++++++++++++++++-- .../Api/V1/AcceptInvitationRequest.php | 3 +- .../Api/V1/PortalTokenAuthRequest.php | 23 ++++++ .../Resources/Api/V1/PortalEventResource.php | 24 ++++++ api/app/Mail/InvitationMail.php | 2 +- api/app/Models/UserInvitation.php | 6 ++ api/app/Services/InvitationService.php | 8 +- .../factories/UserInvitationFactory.php | 7 +- ...pdate_token_columns_for_hashed_storage.php | 40 +++++++++ api/routes/web.php | 2 +- .../Api/V1/VolunteerRegistrationTest.php | 11 +-- .../Feature/Invitation/InvitationTest.php | 12 +-- 14 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 api/app/Http/Controllers/Api/V1/PortalTokenController.php create mode 100644 api/app/Http/Requests/Api/V1/PortalTokenAuthRequest.php create mode 100644 api/app/Http/Resources/Api/V1/PortalEventResource.php create mode 100644 api/database/migrations/2026_04_14_100000_update_token_columns_for_hashed_storage.php diff --git a/api/app/Http/Controllers/Api/V1/InvitationController.php b/api/app/Http/Controllers/Api/V1/InvitationController.php index eb232e23..88217c11 100644 --- a/api/app/Http/Controllers/Api/V1/InvitationController.php +++ b/api/app/Http/Controllers/Api/V1/InvitationController.php @@ -39,7 +39,9 @@ final class InvitationController extends Controller public function show(string $token): JsonResponse { - $invitation = UserInvitation::where('token', $token) + $hashedToken = hash('sha256', $token); + + $invitation = UserInvitation::where('token', $hashedToken) ->with(['organisation', 'invitedBy']) ->first(); @@ -52,7 +54,8 @@ final class InvitationController extends Controller public function accept(AcceptInvitationRequest $request, string $token): JsonResponse { - $invitation = UserInvitation::where('token', $token)->firstOrFail(); + $hashedToken = hash('sha256', $token); + $invitation = UserInvitation::where('token', $hashedToken)->firstOrFail(); $user = $this->invitationService->accept( $invitation, diff --git a/api/app/Http/Controllers/Api/V1/PortalTokenController.php b/api/app/Http/Controllers/Api/V1/PortalTokenController.php new file mode 100644 index 00000000..865b9bc1 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/PortalTokenController.php @@ -0,0 +1,62 @@ +validated('token')); + + // Try artists table + $artist = DB::table('artists')->where('portal_token', $hashedToken)->first(); + + if ($artist) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id); + + return response()->json([ + 'context' => 'artist', + 'data' => [ + 'id' => $artist->id, + 'name' => $artist->name, + 'booking_status' => $artist->booking_status, + ], + 'event' => $event ? new PortalEventResource($event) : null, + ]); + } + + // Try production_requests table (may not exist yet) + try { + $productionRequest = DB::table('production_requests')->where('token', $hashedToken)->first(); + + if ($productionRequest) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($productionRequest->event_id); + + return response()->json([ + 'context' => 'supplier', + 'data' => [ + 'id' => $productionRequest->id, + 'name' => $productionRequest->name ?? null, + ], + 'event' => $event ? new PortalEventResource($event) : null, + ]); + } + } catch (\Illuminate\Database\QueryException) { + // Table doesn't exist yet — skip + } + + return response()->json([ + 'message' => 'Invalid or expired portal token', + ], 401); + } +} diff --git a/api/app/Http/Middleware/PortalTokenMiddleware.php b/api/app/Http/Middleware/PortalTokenMiddleware.php index e61481ff..7c671403 100644 --- a/api/app/Http/Middleware/PortalTokenMiddleware.php +++ b/api/app/Http/Middleware/PortalTokenMiddleware.php @@ -1,20 +1,87 @@ extractToken($request); + + if ($plainToken === null) { + return response()->json(['message' => 'Portal token required.'], 401); + } + + $hashedToken = hash('sha256', $plainToken); + + // Try artists table + $artist = DB::table('artists')->where('portal_token', $hashedToken)->first(); + + if ($artist) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($artist->event_id); + + if (! $event || in_array($event->status, ['draft', 'closed'], true)) { + return response()->json(['message' => 'Portal token required.'], 401); + } + + $request->merge([ + 'portal_context' => 'artist', + 'portal_person' => $artist, + 'portal_event' => $event, + ]); + + return $next($request); + } + + // Try production_requests table (may not exist yet) + try { + $productionRequest = DB::table('production_requests')->where('token', $hashedToken)->first(); + + if ($productionRequest) { + $event = Event::withoutGlobalScope(OrganisationScope::class)->find($productionRequest->event_id); + + if (! $event || in_array($event->status, ['draft', 'closed'], true)) { + return response()->json(['message' => 'Portal token required.'], 401); + } + + $request->merge([ + 'portal_context' => 'supplier', + 'portal_person' => $productionRequest, + 'portal_event' => $event, + ]); + + return $next($request); + } + } catch (\Illuminate\Database\QueryException) { + // Table doesn't exist yet — skip + } + + return response()->json(['message' => 'Portal token required.'], 401); + } + + private function extractToken(Request $request): ?string + { + // Check Authorization: Bearer header + $bearer = $request->bearerToken(); + if ($bearer !== null && $bearer !== '') { + return $bearer; + } + + // Check query parameter + $queryToken = $request->query('token'); + if (is_string($queryToken) && $queryToken !== '') { + return $queryToken; + } + + return null; } } diff --git a/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php index 318763d6..dbc41646 100644 --- a/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php +++ b/api/app/Http/Requests/Api/V1/AcceptInvitationRequest.php @@ -19,7 +19,8 @@ final class AcceptInvitationRequest extends FormRequest /** @return array */ public function rules(): array { - $invitation = UserInvitation::where('token', $this->route('token'))->first(); + $hashedToken = hash('sha256', $this->route('token')); + $invitation = UserInvitation::where('token', $hashedToken)->first(); $userExists = $invitation && User::where('email', $invitation->email)->exists(); return [ diff --git a/api/app/Http/Requests/Api/V1/PortalTokenAuthRequest.php b/api/app/Http/Requests/Api/V1/PortalTokenAuthRequest.php new file mode 100644 index 00000000..d996c26f --- /dev/null +++ b/api/app/Http/Requests/Api/V1/PortalTokenAuthRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/PortalEventResource.php b/api/app/Http/Resources/Api/V1/PortalEventResource.php new file mode 100644 index 00000000..009302ff --- /dev/null +++ b/api/app/Http/Resources/Api/V1/PortalEventResource.php @@ -0,0 +1,24 @@ + $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'start_date' => $this->start_date?->toDateString(), + 'end_date' => $this->end_date?->toDateString(), + 'status' => $this->status, + 'event_type' => $this->event_type, + ]; + } +} diff --git a/api/app/Mail/InvitationMail.php b/api/app/Mail/InvitationMail.php index 18f38ded..0862720b 100644 --- a/api/app/Mail/InvitationMail.php +++ b/api/app/Mail/InvitationMail.php @@ -32,7 +32,7 @@ final class InvitationMail extends CrewliMailable return new Content( view: 'mail.invitation', with: [ - 'acceptUrl' => config('crewli.app_url') . '/invitations/' . $this->invitation->token . '/accept', + 'acceptUrl' => config('crewli.app_url') . '/invitations/' . ($this->invitation->plainToken ?? $this->invitation->token) . '/accept', 'inviterName' => $this->invitation->invitedBy?->name ?? 'Een beheerder', 'role' => $this->invitation->role, 'expiresAt' => $this->invitation->expires_at, diff --git a/api/app/Models/UserInvitation.php b/api/app/Models/UserInvitation.php index 5b2b239f..abf83081 100644 --- a/api/app/Models/UserInvitation.php +++ b/api/app/Models/UserInvitation.php @@ -15,6 +15,12 @@ final class UserInvitation extends Model use HasFactory; use HasUlids; + /** + * Plain-text token, set transiently after creation for use in emails. + * Never persisted — the DB stores only the SHA-256 hash. + */ + public ?string $plainToken = null; + protected $fillable = [ 'email', 'event_id', diff --git a/api/app/Services/InvitationService.php b/api/app/Services/InvitationService.php index 8325d4ae..1d804a94 100644 --- a/api/app/Services/InvitationService.php +++ b/api/app/Services/InvitationService.php @@ -11,7 +11,6 @@ use App\Models\UserInvitation; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -use Spatie\Activitylog\Facades\LogActivity; final class InvitationService { @@ -37,15 +36,20 @@ final class InvitationService ]); } + $plainToken = bin2hex(random_bytes(32)); + $invitation = new UserInvitation(['email' => $email]); $invitation->invited_by_user_id = $invitedBy->id; $invitation->organisation_id = $org->id; $invitation->role = $role; - $invitation->token = strtolower((string) Str::ulid()); + $invitation->token = hash('sha256', $plainToken); $invitation->status = 'pending'; $invitation->expires_at = now()->addDays(7); $invitation->save(); + // Set transient plain token for use in the email URL + $invitation->plainToken = $plainToken; + Mail::to($email)->queue(new InvitationMail($invitation)); activity('invitation') diff --git a/api/database/factories/UserInvitationFactory.php b/api/database/factories/UserInvitationFactory.php index 11a7a523..cd78b862 100644 --- a/api/database/factories/UserInvitationFactory.php +++ b/api/database/factories/UserInvitationFactory.php @@ -8,7 +8,6 @@ use App\Models\Organisation; use App\Models\User; use App\Models\UserInvitation; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Str; /** @extends Factory */ final class UserInvitationFactory extends Factory @@ -28,7 +27,11 @@ final class UserInvitationFactory extends Factory $invitation->invited_by_user_id ??= User::factory()->create()->id; $invitation->organisation_id ??= Organisation::factory()->create()->id; $invitation->role ??= 'org_member'; - $invitation->token ??= strtolower((string) Str::ulid()); + if ($invitation->token === null) { + $plainToken = bin2hex(random_bytes(32)); + $invitation->token = hash('sha256', $plainToken); + $invitation->plainToken = $plainToken; + } $invitation->status ??= 'pending'; $invitation->expires_at ??= now()->addDays(7); }); diff --git a/api/database/migrations/2026_04_14_100000_update_token_columns_for_hashed_storage.php b/api/database/migrations/2026_04_14_100000_update_token_columns_for_hashed_storage.php new file mode 100644 index 00000000..5bc68b50 --- /dev/null +++ b/api/database/migrations/2026_04_14_100000_update_token_columns_for_hashed_storage.php @@ -0,0 +1,40 @@ +char('token', 64)->change(); + }); + + if (Schema::hasTable('artists')) { + Schema::table('artists', function (Blueprint $table) { + $table->char('portal_token', 64)->change(); + }); + } + } + + public function down(): void + { + Schema::table('user_invitations', function (Blueprint $table) { + $table->char('token', 26)->change(); + }); + + if (Schema::hasTable('artists')) { + Schema::table('artists', function (Blueprint $table) { + $table->char('portal_token', 26)->change(); + }); + } + } +}; diff --git a/api/routes/web.php b/api/routes/web.php index 0b56d2d1..36c44edd 100644 --- a/api/routes/web.php +++ b/api/routes/web.php @@ -29,7 +29,7 @@ if (app()->environment('local', 'staging')) { $invitation = UserInvitation::factory()->make(); $invitation->setRelation('organisation', $organisation); $invitation->setRelation('invitedBy', User::factory()->make()); - $invitation->token ??= 'preview-token'; + $invitation->plainToken ??= 'preview-token'; $invitation->role ??= 'org_member'; $invitation->expires_at ??= now()->addDays(7); diff --git a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php index 873acf79..5d1d9dbb 100644 --- a/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php +++ b/api/tests/Feature/Api/V1/VolunteerRegistrationTest.php @@ -18,7 +18,6 @@ use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Schema; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -402,17 +401,13 @@ class VolunteerRegistrationTest extends TestCase $response->assertJson(['message' => 'Invalid or expired portal token']); } - public function test_token_auth_returns_501_when_no_tables(): void + public function test_token_auth_with_empty_token_returns_422(): void { - // Drop the artists table to simulate no token tables existing - Schema::dropIfExists('artists'); - $response = $this->postJson('/api/v1/portal/token-auth', [ - 'token' => '01JTEST000000000000000000', + 'token' => '', ]); - $response->assertStatus(501); - $response->assertJson(['message' => 'Token-based portal access is not yet available']); + $response->assertStatus(422); } // ─── Portal Me ────────────────────────────────────────────────────── diff --git a/api/tests/Feature/Invitation/InvitationTest.php b/api/tests/Feature/Invitation/InvitationTest.php index 9fc5fbf3..00c68d2a 100644 --- a/api/tests/Feature/Invitation/InvitationTest.php +++ b/api/tests/Feature/Invitation/InvitationTest.php @@ -111,7 +111,7 @@ class InvitationTest extends TestCase 'invited_by_user_id' => $this->orgAdmin->id, ]); - $response = $this->getJson("/api/v1/invitations/{$invitation->token}"); + $response = $this->getJson("/api/v1/invitations/{$invitation->plainToken}"); $response->assertOk(); $response->assertJsonPath('data.organisation.name', $this->org->name); @@ -126,7 +126,7 @@ class InvitationTest extends TestCase 'expires_at' => now()->subDay(), ]); - $response = $this->getJson("/api/v1/invitations/{$invitation->token}"); + $response = $this->getJson("/api/v1/invitations/{$invitation->plainToken}"); $response->assertOk(); $response->assertJsonPath('data.status', 'expired'); @@ -151,7 +151,7 @@ class InvitationTest extends TestCase 'expires_at' => now()->addDays(7), ]); - $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + $response = $this->postJson("/api/v1/invitations/{$invitation->plainToken}/accept", [ 'first_name' => 'New', 'last_name' => 'User', 'password' => 'Password123', @@ -184,7 +184,7 @@ class InvitationTest extends TestCase 'expires_at' => now()->addDays(7), ]); - $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept"); + $response = $this->postJson("/api/v1/invitations/{$invitation->plainToken}/accept"); $response->assertOk(); $response->assertJsonStructure(['data' => ['user', 'token']]); @@ -204,7 +204,7 @@ class InvitationTest extends TestCase 'expires_at' => now()->subDay(), ]); - $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + $response = $this->postJson("/api/v1/invitations/{$invitation->plainToken}/accept", [ 'first_name' => 'Test', 'last_name' => 'User', 'password' => 'Password123', @@ -223,7 +223,7 @@ class InvitationTest extends TestCase 'expires_at' => now()->addDays(7), ]); - $response = $this->postJson("/api/v1/invitations/{$invitation->token}/accept", [ + $response = $this->postJson("/api/v1/invitations/{$invitation->plainToken}/accept", [ 'first_name' => 'Test', 'last_name' => 'User', 'password' => 'Password123',