У меня есть приложение, написанное на PHP 7.3, где я хочу использовать Redis для хранения сеанса, я не использую Redis в качестве обработчика сеанса и вообще не использую $_SESSION
(я действительно пробовал, но проблема заключалась в то же самое, поэтому я его переделываю, но это не помогло)
Что я делаю прямо сейчас:
- когда пользователь пытается войти в систему
- пытается получить пользователя из базы данных по учетным данным
- если учетные данные верны, я генерирую случайный UUID
- сохраняю пользовательские данные в Redis в течение 1800 секунд, добавляя этот UUID к ключу Redis
- сохраняя этот UUID в файлах cookie пользователя
- После успешного входа в систему пользователь перенаправляется на защищенную часть сайта через
SessionAuthenticationMiddleware
и проверку промежуточного программного обеспечения: - если у пользователя есть готовка ie (если нет, это означает, что пользователь не вошел в систему)
- с использованием этого значения cook ie для генерации ключа Redis и попытки получить пользователя из сеанса (если ключ не существует, это означает, что пользователь не вошел в систему)
- если пользователь существует в Redis, я снова сбрасываю ttl до 1800 секунд
- проверяю, являются ли данные, которые я получил от Redis, существующим пользователем (если нет, я удаляю их из Redis, и это также означает, что пользователь не вошел в систему)
- если все правильно , пользователь перенаправлен на защищенную часть страницы
- та же проверка для каждой страницы браузера refre sh
Проблема в том, что по какой-то причине все данные например, стирая из Redis, я вхожу в систему и обновляю страницу 10 раз, в 11-й раз Redis стирает все данные и приложение, которое меня выводит из системы. Это происходит случайно. Иногда я могу войти в систему на 5 минут, иногда на несколько секунд. Я добавляю записи журнала, чтобы поместить их в код, отвечающий за удаление данных из Redis, и он никогда не вызывается. Это происходит на AWS (я использую собственный Redis, установленный на экземпляре EC2), на моем локальном компьютере все работает нормально.
Не уверен, нужен ли код, но я поделюсь им:
LoginService - получение пользователя из базы данных и передача его в службу UserSession
:
public function login(string $email, string $password): void
{
$userData = $this->usersRepository->verifyCredentialsAndGetUser($email, $password);
if (empty($userData)) {
throw new Exception('Incorrect credentials provided');
}
$this->userSession->setUserLoginSession($userData);
}
UserSession - организация сеанса и готовка ie хранилище для сохранения / получения / проверки пользовательского сеанса
class UserSession
{
public const AUTH_KEY = 'SOME_KEY';
/**
* @var RedisSessionHandler
*/
private $session;
/**
* @var AuthCookie
*/
private $authCookie;
public function __construct(RedisSessionHandler $session, AuthCookie $authCookie)
{
$this->session = $session;
$this->authCookie = $authCookie;
}
public function setUserLoginSession(array $userData): void
{
$this->authCookie->generateUserCookie();
$uuid = $this->authCookie->getUserCookie();
$this->session->write(self::AUTH_SESSION_KEY . ':' . $uuid, json_encode($userData));
}
public function clearUserLoginSession(): void
{
$uuid = $this->authCookie->getUserCookie();
$this->authCookie->clearUserCookie();
$this->session->destroy(self::AUTH_SESSION_KEY . ':' . $uuid);
}
public function getCurrentUser(): ?array
{
if ($this->authCookie->getUserCookie() === false) {
return null;
}
$uuid = $this->authCookie->getUserCookie();
$userData = $this->session->read(self::AUTH_SESSION_KEY . ':' . $uuid);
if (!empty($userData)) {
return json_decode($userData, true);
}
return null;
}
public function isAuthorized(): bool
{
if ($this->getCurrentUser() === null) {
return false;
}
$uuid = $this->authCookie->getUserCookie();
$authorized = $this->session->read(self::AUTH_SESSION_KEY . ':' . $uuid);
if (empty($authorized) || $authorized === false) {
return false;
}
return true;
}
}
AuthCook ie - оболочка для управления данными, связанными с аутентификацией пользователя, сохранение / получение / удаление из Cook ie
class AuthCookie
{
public const AUTH_COOKIE_USER = '_user_session';
private $ttl = 1800; // 30 minutes default
/**
* @var CookieInterface
*/
private $cookie;
public function __construct(CookieInterface $cookie)
{
$this->cookie = $cookie;
}
public function generateUserCookie(bool $forever = false): bool
{
$uuid = Uuid::uuid4();
if ($forever === true) {
return $this->cookie->forever(self::AUTH_COOKIE_USER, $uuid->toString());
}
return $this->cookie->set(self::AUTH_COOKIE_USER, $uuid->toString(), $this->ttl);
}
public function hasUserCookie(): bool
{
return $this->getUserCookie() !== null;
}
public function getUserCookie(): ?string
{
return $this->cookie->get(self::AUTH_COOKIE_USER);
}
public function clearUserCookie(): bool
{
return $this->cookie->delete(self::AUTH_COOKIE_USER);
}
}
Cook ie - базовый повар ie класс для хранения повара ie любого вида
class Cookie implements CookieInterface
{
/**
* $_COOKIE global variable
*
* @var array
*/
private $cookie;
public function __construct()
{
$this->cookie = $_COOKIE;
}
public function get(string $name): ?string
{
if (!isset($this->cookie[$name])) {
return null;
}
return strip_tags(stripslashes($this->cookie[$name]));
}
public function set(
string $name,
?string $value,
int $expire = 0,
?string $path = null,
?string $domain = null,
?bool $secure = false,
?bool $httpOnly = false
): bool {
setcookie(
$name,
$value ?? '',
$this->calculateExpirationTime($expire),
$path ?? '',
$domain ?? '',
$secure ?? false,
$httpOnly ?? false
);
$this->cookie[$name] = $value;
return true;
}
public function forever(string $name, string $value): bool
{
$this->set( $name, $value, 31536000 * 5);
$this->cookie[$name] = $value;
}
public function delete(string $name): bool
{
unset($this->cookie[$name]) ;
return $this->set($name, null, time() - 15 * 60 );
}
private function calculateExpirationTime( $expire = 0 ): int
{
return (int)($expire > 0 ? time() + $expire : -1 );
}
}
RedisSessionHandler - отвечает за манипуляции с данными, связанными с сессией сохранить / получить / удалить из Redis. В настоящее время я не устанавливаю его как обработчик сеанса PHP (session_set_save_handler()
), но я попытался, и проблема была той же.
class RedisSessionHandler implements \SessionHandlerInterface
{
private $ttl = 1800; // 30 minutes default
private $db;
private $prefix;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(LoggerInterface $logger, \Redis $db, string $prefix = 'PHPSESSID:', int $ttl = 1800) {
$this->db = $db;
$this->prefix = $prefix;
$this->ttl = $ttl;
$this->logger = $logger;
}
public function open($savePath, $sessionName): bool
{
return true;
}
public function close(): bool
{
return true;
}
public function read($id) {
$id = $this->getRedisKey($id);
$this->logger->debug(
sprintf(
'%s Reading data for id %s',
__CLASS__,
$id
)
);
$sessData = $this->db->get($id);
$this->logger->debug(
sprintf(
'%s Result of reading data for id %s is: %s',
__CLASS__,
$id,
$sessData
)
);
if (empty($sessData)) {
return '';
}
$this->logger->debug(
sprintf(
'%s time to leave for id %s is %s ',
__CLASS__,
$id,
$this->db->ttl($id)
)
);
$this->db->expire($id, $this->ttl);
return $sessData;
}
public function write($id, $data) : bool
{
$id = $this->getRedisKey($id);
$this->logger->debug(
sprintf(
'%s Writing data %s for id %s',
__CLASS__,
$data,
$id
)
);
$result = $this->db->set($id, $data, $this->ttl);
$this->logger->debug(
sprintf(
'%s Write result for id %s is %s and expiration %s',
__CLASS__,
$id,
$result === true ? 'true' : 'false',
$this->ttl
)
);
return true;
}
public function destroy($id): bool
{
$id = $this->getRedisKey($id);
// this method never called, no log entiries written so a assume that application is not
// deleting redis key
$this->logger->debug(
sprintf(
'%s Destroy id %s ',
__CLASS__,
$id
)
);
$this->db->del($id);
$this->close();
return true;
}
public function gc($maxLifetime): bool
{
return true;
}
protected function getRedisKey($key)
{
if (empty($this->prefix)) {
return $key;
}
return $this->prefix . $key;
}
}
SessionAuthenticationMiddleware - отвечает за проверку того, вошел ли пользователь в систему или нет, и перенаправляет в соответствующее место.
final class SessionAuthenticationMiddleware implements MiddlewareInterface
{
/**
* @var UserSession
*/
private $userSession;
/**
* @var RouteCollectorInterface
*/
private $routeCollector;
/**
* @var UserRepositoryInterface
*/
private $userRepo;
public function __construct(
UserSession $session,
RouteCollectorInterface $routeCollector,
UserRepositoryInterface $userRepositoryInterface
) {
$this->userSession = $session;
$this->routeCollector = $routeCollector;
$this-userRepo = userRepositoryInterface
}
/**
* @inheritDoc
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var Route $currentRoute */
$currentRoute = $request->getAttribute(RouteContext::ROUTE);
$isAuthorized = $this->isAuthorised();
// if route is guarded and user not authorised redirect to login
if ($isAuthorized === false && $this->isGuardedRoute($currentRoute)) {
return $this->notAuthorizedResponse($request);
}
// if route is not guarded but user is authorized redirect to admin dashboard,
// also we need to check if provided user exist in our system,
// if not destroy the session and redirect to login
if ($isAuthorized && $this->isGuardedRoute($currentRoute) === false) {
return $this->redirectToDashboard();
}
return $handler->handle($request);
}
private function isGuardedRoute(Route $route): bool
{
return in_array($route->getName() , ['get.login', 'post.login'], true) === false;
}
private function isAuthorised(): bool
{
if (!$this->userSession->isAuthorized()) {
return false;
}
$userSession = $this->userSession->getCurrentUser();
if (empty($userSession)) {
return false;
}
try {
$userExist= $this-userRepo->getUser(
Uuid::fromString(
$userSession['id']
)
);
if ($userExist === false) {
$this->userSession->clearUserLoginSession();
return false;
}
} catch (ApplicationException $exception) {
$this->logger->info($exception->getMessage(), ['exception' => $exception]);
$this->userSession->clearUserLoginSession();
return false;
} catch (ApiException $exception) {
$this->logger->warning($exception->getMessage(), ['exception' => $exception]);
$this->userSession->clearUserLoginSession();
return false;
}
return true;
}
private function notAuthorizedResponse(RequestInterface $request): Response
{
$response = new Response();
if ($request->getHeaderLine('X-Requested-With') === 'xmlhttprequest') {
$response->getBody()->write(
json_encode(
[
'error' => [
'message' => 'Not authorized'
],
],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)
);
return $response->withHeader('Content-Type', 'application/json')
->withStatus(302);
}
return $response
->withHeader('Location', $this->routeCollector->getRouteParser()->urlFor('get.login'))
->withStatus(302);
}
private function redirectToDashboard(): Response
{
$response = new Response();
return $response
->withHeader('Location', $this->routeCollector->getRouteParser()->urlFor('get.dashboard'))
->withStatus(302);
}
}
Я уже часами смотрю, что не так, и думаю что-то может быть не так с самой конфигурацией Redis / PHP, но не в коде, но я не знаю, что мне нужно искать.