Skip to content

Commit 5d6d29f

Browse files
committed
✨ WIP: added custom CSRF middleware for SPA routes
Introduced `VerifySpaCsrfToken` middleware to enhance CSRF handling for SPAs. Configured middleware stack with conditional CSRF verification based on config flags. Added configuration options for CSRF cookie customization in `config/socialment.php`.
1 parent 1dddef2 commit 5d6d29f

File tree

4 files changed

+221
-10
lines changed

4 files changed

+221
-10
lines changed

config/socialment.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
// Replace with your own JsonResource class if you want to customize the response
3838
// 'me' => \ChrisReedIO\Socialment\Http\Resources\UserResponse::class,
3939
],
40+
'cookies' => [
41+
'csrf' => [
42+
'custom' => false, // Experimental
43+
'name' => env('SOCIALMENT_SPA_CSRF_NAME', 'XSRF-TOKEN'),
44+
'header' => env('SOCIALMENT_SPA_CSRF_HEADER', 'X-XSRF-TOKEN'),
45+
'domain' => env('SOCIALMENT_SPA_DOMAIN', env('SESSION_DOMAIN')),
46+
],
47+
],
4048
],
4149

4250
'models' => [

routes/spa.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,27 @@
33
use ChrisReedIO\Socialment\Http\Controllers\CsrfCookieController;
44
use ChrisReedIO\Socialment\Http\Controllers\SocialmentController;
55
use ChrisReedIO\Socialment\Http\Controllers\SpaAuthController;
6+
use ChrisReedIO\Socialment\Http\Middleware\VerifySpaCsrfToken;
7+
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
8+
use Illuminate\Cookie\Middleware\EncryptCookies;
9+
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
10+
use Illuminate\Routing\Middleware\SubstituteBindings;
11+
use Illuminate\Session\Middleware\StartSession;
612
use Illuminate\Support\Facades\Route;
13+
use Illuminate\View\Middleware\ShareErrorsFromSession;
714

15+
// Define middleware stack, replacing VerifyCsrfToken if config flag is set
16+
// $spaMiddleware = [
17+
// EncryptCookies::class,
18+
// AddQueuedCookiesToResponse::class,
19+
// StartSession::class,
20+
// ShareErrorsFromSession::class,
21+
// config('socialment.spa.cookies.csrf.custom') ? VerifySpaCsrfToken::class : VerifyCsrfToken::class,
22+
// SubstituteBindings::class,
23+
// ];
24+
25+
// Apply custom middleware stack to SPA routes
26+
// Route::middleware($spaMiddleware)->group(function () {
827
// Custom SPA specific route for getting a CSRF cookie
928
Route::get('sanctum/csrf-cookie', [CsrfCookieController::class, 'show'])->name('csrf-cookie');
1029
// Non-Social User Login
@@ -13,7 +32,9 @@
1332
Route::get('login/{provider}', [SocialmentController::class, 'redirectSpa'])
1433
->name('redirect');
1534
// Authenticated Routes
16-
Route::middleware(['auth:sanctum'])->group(function () {
35+
// Route::middleware(['auth:sanctum'])->group(function () {
36+
Route::middleware(['auth'])->group(function () {
1737
Route::post('logout', [SpaAuthController::class, 'logout'])->name('logout');
1838
Route::get('me', [SpaAuthController::class, 'me'])->name('me');
1939
});
40+
// });
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace ChrisReedIO\Socialment\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Encryption\DecryptException;
7+
use Illuminate\Cookie\CookieValuePrefix;
8+
use Illuminate\Cookie\Middleware\EncryptCookies;
9+
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
10+
use Illuminate\Http\Request;
11+
use Symfony\Component\HttpFoundation\Cookie;
12+
use Symfony\Component\HttpFoundation\Response;
13+
14+
class VerifySpaCsrfToken extends VerifyCsrfToken
15+
{
16+
/**
17+
* Get the CSRF token from the request.
18+
*
19+
* @param Request $request
20+
* @return string|null
21+
*/
22+
protected function getTokenFromRequest($request): ?string
23+
{
24+
$headerName = config('socialment.spa.cookies.csrf.header', 'X-XSRF-TOKEN');
25+
$token = $request->input('_token') ?: $request->header($headerName);
26+
27+
if (! $token && $header = $request->header($headerName)) {
28+
try {
29+
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
30+
} catch (DecryptException) {
31+
$token = '';
32+
}
33+
}
34+
35+
return $token;
36+
}
37+
38+
/**
39+
* Create a new "XSRF-TOKEN" cookie that contains the CSRF token.
40+
*
41+
* @param Request $request
42+
* @param array $config
43+
* @return Cookie
44+
*/
45+
protected function newCookie($request, $config): Cookie
46+
{
47+
return new Cookie(
48+
config('socialment.spa.cookies.csrf.name', 'XSRF-TOKEN'),
49+
$request->session()->token(),
50+
$this->availableAt(60 * $config['lifetime']),
51+
$config['path'],
52+
config('socialment.spa.cookies.csrf.domain') ?? $config['domain'],
53+
$config['secure'],
54+
false,
55+
false,
56+
$config['same_site'] ?? null,
57+
$config['partitioned'] ?? false
58+
);
59+
}
60+
61+
/**
62+
* Determine if the cookie contents should be serialized.
63+
*
64+
* @return bool
65+
*/
66+
public static function serialized(): bool
67+
{
68+
return EncryptCookies::serialized(config('socialment.spa.cookies.csrf.name', 'XSRF-TOKEN'));
69+
}
70+
}

src/SocialmentServiceProvider.php

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,30 @@
22

33
namespace ChrisReedIO\Socialment;
44

5+
use ChrisReedIO\Socialment\Http\Middleware\VerifySpaCsrfToken;
56
use ChrisReedIO\Socialment\Testing\TestsSocialment;
67
use Filament\Support\Assets\Asset;
78
use Filament\Support\Facades\FilamentAsset;
89
use Filament\Support\Facades\FilamentIcon;
910
use Illuminate\Filesystem\Filesystem;
11+
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
1012
use Illuminate\Support\Facades\Route;
1113
use Livewire\Features\SupportTesting\Testable;
1214
use Spatie\LaravelPackageTools\Commands\InstallCommand;
1315
use Spatie\LaravelPackageTools\Package;
1416
use Spatie\LaravelPackageTools\PackageServiceProvider;
1517

18+
use function array_merge;
19+
use function config;
20+
1621
class SocialmentServiceProvider extends PackageServiceProvider
1722
{
1823
public static string $name = 'socialment';
1924

2025
public static string $viewNamespace = 'socialment';
2126

27+
public static string $middlewareGroupName = 'web_spa';
28+
2229
public function configurePackage(Package $package): void
2330
{
2431
/*
@@ -59,18 +66,28 @@ public function configurePackage(Package $package): void
5966
public function packageRegistered(): void
6067
{
6168
$this->app->singleton(SocialmentPlugin::class, fn () => new SocialmentPlugin());
62-
}
6369

64-
public function packageBooted(): void
65-
{
66-
Route::macro('spaAuth', function (string $prefix = 'spa') {
70+
Route::macro('spaInit', function (string $prefix = 'spa') {
6771
$namePrefix = 'socialment.spa.';
6872
$namePrefix .= ($prefix === 'spa') ? 'default.' : "{$prefix}.";
6973

70-
Route::middleware('web')
71-
->prefix($prefix)
72-
->as($namePrefix)
73-
->group(__DIR__ . '/../routes/spa.php');
74+
$useCustomCsrf = config('socialment.spa.cookies.csrf.custom');
75+
76+
$dashboardSpaMiddleware = [
77+
\Illuminate\Cookie\Middleware\EncryptCookies::class,
78+
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
79+
\Illuminate\Session\Middleware\StartSession::class,
80+
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
81+
$useCustomCsrf
82+
? \ChrisReedIO\Socialment\Http\Middleware\VerifySpaCsrfToken::class
83+
: \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
84+
\Illuminate\Routing\Middleware\SubstituteBindings::class,
85+
];
86+
87+
Route::middlewareGroup(SocialmentServiceProvider::$middlewareGroupName, $dashboardSpaMiddleware);
88+
89+
// Route::middleware([SocialmentServiceProvider::$middlewareGroupName])
90+
// ->group(__DIR__.'/../routes/spa.php');
7491

7592
// Now add this to the cors paths
7693
config([
@@ -83,8 +100,103 @@ public function packageBooted(): void
83100
config([
84101
'cors.supports_credentials' => true,
85102
]);
103+
// collect(Route::getRoutes())
104+
// ->filter(fn ($route) => str_starts_with($route->uri(), $prefix))
105+
// ->each(function (\Illuminate\Routing\Route $route) use ($dashboardSpaMiddleware) {
106+
// $route->action['middleware'] = array_values(array_diff($route->action['middleware'], ['web']));
107+
// $route->middleware($dashboardSpaMiddleware);
108+
// });
86109
});
87110

111+
Route::macro('spaAuth', function (string $prefix = 'spa') {
112+
$namePrefix = 'socialment.spa.';
113+
$namePrefix .= ($prefix === 'spa') ? 'default.' : "{$prefix}.";
114+
115+
// $useCustomCsrf = config('socialment.spa.cookies.csrf.custom');
116+
// dd($useCustomCsrf);
117+
118+
119+
// $dashboardSpaMiddleware = [
120+
// \Illuminate\Cookie\Middleware\EncryptCookies::class,
121+
// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
122+
// \Illuminate\Session\Middleware\StartSession::class,
123+
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
124+
// $useCustomCsrf
125+
// ? \ChrisReedIO\Socialment\Http\Middleware\VerifySpaCsrfToken::class
126+
// : \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
127+
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
128+
// ];
129+
//
130+
// // dd($dashboardSpaMiddleware);
131+
//
132+
// Route::middlewareGroup(SocialmentServiceProvider::$middlewareGroupName, $dashboardSpaMiddleware);
133+
134+
Route::middleware([SocialmentServiceProvider::$middlewareGroupName])
135+
// ->prefix($prefix)
136+
// ->as($namePrefix)
137+
->group(__DIR__.'/../routes/spa.php');
138+
139+
// Now add this to the cors paths
140+
// config([
141+
// 'cors.paths' => array_merge(config('cors.paths'), [
142+
// "{$prefix}/*",
143+
// ]),
144+
// ]);
145+
146+
// Set the supports_credentials flag or the frontend can't send the goodies
147+
// config([
148+
// 'cors.supports_credentials' => true,
149+
// ]);
150+
151+
// Apply middleware conditionally to all routes with the '/dashboard' prefix
152+
// Route::middlewareGroup('spa', [
153+
// \Illuminate\Cookie\Middleware\EncryptCookies::class,
154+
// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
155+
// \Illuminate\Session\Middleware\StartSession::class,
156+
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
157+
// config('socialment.spa.cookies.csrf.custom')
158+
// ? VerifySpaCsrfToken::class
159+
// : VerifyCsrfToken::class,
160+
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
161+
// ]);
162+
163+
164+
// Apply 'dashboard_spa' group to any routes prefixed with '/dashboard'
165+
// Route::middleware('spa')
166+
// ->prefix('dashboard')
167+
// ->group(function () {
168+
// //
169+
// });
170+
// Use the `Route` facade to get all routes and modify them if they start with `/dashboard`
171+
// collect(Route::getRoutes())
172+
// ->filter(fn ($route) => str_starts_with($route->uri(), $prefix))
173+
// ->each(function (\Illuminate\Routing\Route $route) use ($dashboardSpaMiddleware) {
174+
// // if ($route->uri() === 'dashboard/me') {
175+
// // dump($route);
176+
// // }
177+
// // dump($route);
178+
// // Replace the middleware stack for this route
179+
// // Unset any 'values' of 'web' in the action's middleware
180+
// $route->action['middleware'] = array_values(array_diff($route->action['middleware'], ['web']));
181+
// // Insert our middleware group name at the start of the array
182+
// // array_unshift($route->action['middleware'], SocialmentServiceProvider::$middlewareGroupName);
183+
// // $route->middleware(SocialmentServiceProvider::$middlewareGroupName);
184+
// $route->middleware($dashboardSpaMiddleware);
185+
// // if ($route->uri() === 'dashboard/me') {
186+
// // dump($route);
187+
// // $middleware = $route->gatherMiddleware();
188+
// // dd($middleware);
189+
// // dd('done');
190+
// // }
191+
// // }
192+
// });
193+
// dd('done');
194+
});
195+
196+
}
197+
198+
public function packageBooted(): void
199+
{
88200
// Asset Registration
89201
FilamentAsset::register(
90202
$this->getAssets(),
@@ -101,7 +213,7 @@ public function packageBooted(): void
101213

102214
// Handle Stubs
103215
if (app()->runningInConsole()) {
104-
foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) {
216+
foreach (app(Filesystem::class)->files(__DIR__.'/../stubs/') as $file) {
105217
$this->publishes([
106218
$file->getRealPath() => base_path("stubs/socialment/{$file->getFilename()}"),
107219
], 'socialment-stubs');

0 commit comments

Comments
 (0)