Проблемы с аутентификацией между Next.js 15 и Laravel 11 Sanctum (401 Unauthenticated)
Я работаю над приложением с фронтендом на Next.js и бэкендом на Laravel 11, используя Laravel Sanctum для аутентификации. Столкнулся с проблемой: после успешного входа пользователя я не могу получить доступ к защищённым маршрутам, и сервер возвращает статус 401 Unauthenticated при запросах из middleware Next.js.
Описание проблемы:
После входа на странице /login я получаю данные пользователя, и в консоли браузера вижу:
Сессия активна, пользователь:
{ id: 1, name: 'Admin', email: '[email protected]', ... }
Однако при переходе на /admin происходит перенаправление обратно на /login, и в консоли Next.js (серверная часть) появляется:
Статус ответа: 401
Данные ответа: { message: 'Unauthenticated.' }
Сессия недействительна. Перенаправление на /login.
В логах Laravel вижу успешный вход пользователя:
[2024-10-30 19:16:26] local.INFO: Попытка входа {"email":"[email protected]"}
[2024-10-30 19:16:26] local.INFO: Успешный вход: [email protected]
Что я использую:
Next.js на фронтенде Laravel 11 на бэкенде Laravel Sanctum для аутентификации Сессионная аутентификация (не токены API)
Что я пробовал:
Передача cookies из запроса в middleware Next.js:
В middleware.ts я попытался передать cookies из исходного запроса в запрос к Laravel API:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const laravelSession = request.cookies.get('laravel_session');
if (!laravelSession || !laravelSession.value) {
console.log('Сессия не найдена. Перенаправление на /login.');
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
try {
const cookie = request.headers.get('cookie') || '';
const response = await fetch('http://localhost:8000/api/user', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cookie': cookie,
},
});
console.log('Статус ответа:', response.status);
const data = await response.json();
console.log('Данные ответа:', data);
if (response.status !== 200) {
console.log('Сессия недействительна. Перенаправление на /login.');
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
console.log('Сессия подтверждена.');
} catch (error) {
console.error('Ошибка проверки сессии:', error);
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin'],
};
Обновление SANCTUM_STATEFUL_DOMAINS и CORS:
В файле .env добавил localhost:
SANCTUM_STATEFUL_DOMAINS=localhost:3000,localhost
SESSION_DOMAIN=localhost
В config/sanctum.php:
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:3000,localhost')),
В config/cors.php:
'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000', 'http://localhost'],
'allowed_headers' => ['*'],
'supports_credentials' => true,
Проверка сессии и cookies:
Cookies laravel_session и XSRF-TOKEN устанавливаются после входа. При запросах из браузера (useEffect на клиенте) я могу получить данные пользователя с /api/user.
Проверка middleware в Laravel:
В routes/api.php маршрут /user настроен так:
Route::middleware([
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'auth:sanctum',
])->get('/user', function (Request $request) {
return response()->json($request->user());
});
Очистка кэша и перезапуск серверов:
Выполнил php artisan config:clear и php artisan cache:clear. Перезапустил сервер Laravel и Next.js. Результат:
При запросе из middleware Next.js к http://localhost:8000/api/user всё ещё получаю статус 401 Unauthenticated.
В консоли Next.js вижу:
Статус ответа: 401
Данные ответа: { message: 'Unauthenticated.' }
Сессия недействительна. Перенаправление на /login.
Дополнительная информация:
Запросы из браузера работают корректно и возвращают данные пользователя. Проблема возникает только при запросах из middleware Next.js на серверной стороне. Похоже, что сервер Laravel не распознаёт сессию при серверных запросах из Next.js.
Файлы bootstrap app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Routing\Middleware\SubstituteBindings;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Глобальные middleware
$middleware->append([
\Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
\Illuminate\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Http\Middleware\ValidatePostSize::class,
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
]);
// Группа middleware 'web'
$middleware->web(append: [
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
// Группа middleware 'api'
$middleware->group('api', [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
routes web.php
<?php
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
Route::post('/login', function (Request $request) {
Log::info('Попытка входа', ['email' => $request->input('email')]);
$credentials = $request->only('email', 'password');
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
Log::info('Успешный вход: ' . $request->input('email'));
return response()->json(['message' => 'Login successful']);
}
Log::warning('Неверные учетные данные', ['email' => $request->input('email')]);
return response()->json(['message' => 'Invalid credentials'], 401);
})->name('login');
Route::post('/logout', function (Request $request) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
Log::info('Пользователь вышел');
return response()->json(['message' => 'Logout successful']);
});
api.php
<?php
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
Route::middleware([
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'auth:sanctum',
])->get('/user', function (Request $request) {
return $request->user()
? response()->json($request->user())
: response()->json(['message' => 'Unauthorized'], 401);
});
Route::get('/check-session', function (Request $request) {
return response()->json([
'session_id' => $request->session()->getId(),
'user' => $request->user(),
'session_data' => $request->session()->all()
]);
});
next js login page.tsx
"use client";
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSessionActive, setIsSessionActive] = useState(false);
useEffect(() => {
const checkSession = async () => {
try {
const response = await fetch('http://localhost:8000/api/user', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const user = await response.json();
console.log('Сессия активна, пользователь:', user);
setIsSessionActive(true);
router.push('/admin');
}
} catch (error) {
console.log('Активная сессия не найдена.');
}
};
checkSession();
}, [router]);
const getCsrfToken = async () => {
const response = await fetch('http://localhost:8000/sanctum/csrf-cookie', {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Не удалось получить CSRF-токен: ${response.status}`);
}
};
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitting || isSessionActive) return;
setIsSubmitting(true);
try {
await getCsrfToken();
const xsrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
if (!xsrfToken) {
alert('Ошибка: отсутствует XSRF-токен. Пожалуйста, попробуйте снова.');
return;
}
const response = await fetch('http://localhost:8000/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(xsrfToken || ''),
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (response.ok) {
router.push('/admin');
} else {
alert('Неверные учетные данные или ошибка сессии.');
}
} catch (error) {
alert('Произошла ошибка во время входа. Пожалуйста, попробуйте снова.');
} finally {
setIsSubmitting(false);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" disabled={isSubmitting}>Войти</button>
</form>
</div>
);
}
middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const laravelSession = request.cookies.get('laravel_session');
if (!laravelSession || !laravelSession.value) {
console.log('Сессия не найдена. Перенаправление на /login.');
// Перенаправление на страницу входа фронтенда
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
try {
// Извлекаем cookies из исходного запроса
const cookie = request.headers.get('cookie') || '';
const response = await fetch('http://localhost:8000/api/user', {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cookie': cookie, // Передаём cookies в запросе
},
});
console.log('Статус ответа:', response.status);
const data = await response.json();
console.log('Данные ответа:', data);
if (response.status !== 200) {
console.log('Сессия недействительна. Перенаправление на /login.');
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
console.log('Сессия подтверждена.');
} catch (error) {
console.error('Ошибка проверки сессии:', error);
return NextResponse.redirect(new URL('/login', request.nextUrl.origin));
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin'],
};
Вопрос:
Как правильно настроить аутентификацию между Next.js и Laravel Sanctum, чтобы middleware Next.js мог успешно проверять сессию пользователя при серверных запросах?
Почему Laravel возвращает статус 401 при запросе из middleware, хотя сессия активна и cookies передаются?
Может ли проблема быть связана с тем, что middleware Next.js выполняется на сервере, и Laravel Sanctum не распознаёт его как "stateful" запрос?
Буду очень благодарен за любую помощь или подсказки по решению этой проблемы. Если нужна дополнительная информация или код, я готов предоставить.
Спасибо!