Онлайн имплементация localStorage

в 16:26, , рубрики: javascript, key-value, localStorage, mongodb, node.js, nodejs, onlineStorage, storage, хранилища данных

Хочу поделиться тем, как приватный режим Safari привел к разработке простого ключ-значение хранилища на Node.js с резервным копированием, доступом к данным с определенных доменов и защитой паролем от записи и очистки хранилища.

Онлайн имплементация localStorage - 1

Все началось с того, что мне дали задачу, реализовать тестовый заказ в веб-приложении, которая встроена через iframe в одном популярном ресурсе.

Задача была решена и работала следующим образом:

  1. неавторизованный пользователь кликает на магазин (ссылка «_blank»);
  2. в новом окне отображаются тестовые товары, а в iframe мы перенаправляем пользователя в профиль тестового пользователя и ждем появления данных покупки в localStorage;
  3. после совершения покупки, данные о ней сохраняем в localStorage (сумма, количество, магазин, время покупки и количество бонусов)
  4. в iframe при появлении данных тестовой покупки в localStorage, мы отображаем информацию в блоке «история покупок»;

Все работало в большинстве браузеров, и даже в IE11, но только не в Safari, чья политика безопастности (более известный как porno-mode) не разрешала получить доступ к данным localStorage одного и того же домена внутри iframe и снаружи (в новом окне).

Нужно где-то хранить промежуточные данные, привлечь к этой задачи бэкенд разработчиков для создания какого-либо API для хранения данных разрешения не получил, оставалось только найти какое-нибудь онлайн хранилище, с возможностью создание для каждого пользователя своего токена.

Поиски привели меня к сервису keyvalue.xyz, он позволяет создавать ключ, записывать и считывать данные. И так я начал для каждого пользователя, который решил попробовать тестовый заказ, создавать токен и передавать его в url параметрах в новое окно, далее при успешном тестовом заказе, записываем данные в хранилище, а уже в iframe переодически запрашивал данные, пока они не появятся.


Все работало, но тут пришло сообщение от тестировщика, на этот раз она сообщила, что с включенным adblock’ом не работает тестовый заказ. Так и есть, в консоли adblock писал, о том что заблокирован запрос к ресурсу. Я обратился к разработчикам сервиса, с просьбой сделать зеркало, они не ответили, попытался через nginx (proxy_pass) сделать зеркало, тоже не помогло, скорее всего из-за фильтра cloudflare.


Было не приятно, нужно было выходить из ситуации.

Решил написать простое ключ=значение хранилище подобное localStorage, с бэкапом, доступом с определенного домена, защитой паролем от записи и удобную библиотеку для работы с ним.

Разработка

Написать на Node.js простой rest api c помощью express не составляет труда, для хранения данных я выбрал MongoDB, потому что нет жесткой структуры и изменить структуру документа можно всего лишь одной строчкой кода в схеме и конечно то, что mongodb может работать с документами большого размера (100-200Гб).

Подробно рассказывать о разработке не имеет смысла, она очень проста и большинство из нас уже пользовались фреймворком express.

Начнем с основных требований к хранилищу:

  1. Создание токена
  2. Обновление токена
  3. Получение значения из хранилища
  4. Получение всего хранилища
  5. Запись данных
  6. Удаление элемента
  7. Очистка хранилища
  8. Получения списка резервных копий
  9. Восстановление хранилища из резервной копии

Схема токена достаточно простая, выглядит следующим образом:

const TokenSchema = new db.mongoose.Schema({
  token: { type: String, required: [true, "tokenRequired"] },
  connect: { type: String, required: [true, "connectRequired"] },
  refreshToken: { type: String, required: [true, "refreshTokenRequired"] },
  domains: { type: Array, default: [] },
  backup: { type: Boolean, default: false },
  password: { type: String },
})

Дополнительные параметры:

token Используется для доступа к хранилищу
connect Свойство для связки хранилища с токеном
refreshToken Обновление токена в случаи,
если нужно обновить токен или токен где-то засветился,
например в git коммите
domains Массив доменов, доступ к хранилищу, которым разрешен.
Для проверки используется HTTP заголовок Origin
backup Если установлено true, то каждые 2 часа будет выполнено
резервное копирование всего хранилище,
то есть в течении суток всегда доступно несколько резервных копий,
к которым можно откатиться
password Установка пароля для записи и удаления

Обработчик POST запроса /create

  app.post('/create', async (req, res) => {
    try {
      // Additional storage protection data
      const { domains, backup, password } = req.body
      // New unique uuid token
      const token = uuid.v4()
      // A unique identifier for connecting the token to the storage 
      // as well as using it you can update the token
      const connect = uuid.v1()

      // Default
      const tokenParam = {
        token: token,
        connect: connect,
        refreshToken: connect
      }

      // The list of domains for accessing the repository
      if (domains) {
        // If an array is passed, store it as it is
        if (Array.isArray(domains)) tokenParam.domains = domains
        // If a string is passed, wrap it in an array
        if (typeof domains === 'string') tokenParam.domains = [domains]
        // If passed boolean true, save the host
        if (typeof domains === 'boolean') tokenParam.domains = [req.hostname]
      }

      // Availability of backup
      if (backup) tokenParam.backup = true

      // If a password is sent, we save it
      if (password) tokenParam.password = md5(password)

      // Save to db
      await new TokenModel.Token(tokenParam).save()

      // Sending the token to the client
      res.json({ status: true, data: tokenParam })
    } catch (e) {
      res.status(500).send({ status: false, description: 'Error: There was an error creating the token' })
    }
  })

Примеры запросов будет приводить с использованием библиотеки axios, в отличии от curl команды, его код достаточно лаконичен и понятен.

axios.post('https://api.kurtuba.ru/create', {
  domains: ['example.com', 'google.com'],
  backup: true,
  password: 'qwerty'
})

В результате выполнения мы получим ответ с токеном, который можно использовать для записи и чтения данных с хранилища:

{
  "status":  true,
  "data":{
    "token": "002cac23-aa8b-4803-a94f-3888020fa0df",
    "refreshToken": "5bf365e0-1fc0-11e8-85d2-3f7a9c4f742e",
    "domains": ["example.com", "google.com"],
    "backup": true,
    "password": "d8578edf8458ce06fbc5bb76a58c5ca4"
  }
}

Запись данных в хранилище:

axios.post('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/set', {
  name: 'hazratgs',
  age: 25,
  city: 'Derbent'
  skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})

Получение элемента из хранилища:

axios.get('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/get/name')

В результате мы получим:

{
  "status":  true,
  "data": "hazratgs"
}

Остальные примеры вы можете посмотреть на странице проекта в GitHub..

Вы можете клонировать репозиторий и развернуть хранилище у себя, подробная инструкция в репозитории проекта.

Для удобства доступна JavaScript библиотека и Python библиотека.
Само хранилище доступно по адресу api.kurtuba.ru

Пример работы с библиотекой

И так у нас уже есть хранилище и библиотека для работы с ним, давайте установим библиотеку:

npm i kurtuba-client

Импортируем в проект:

import onlineStorage from 'kurtuba-client'

Хочу заметить, так как мы делаем онлайн имплементацию localStorage, в коде проекта по умолчанию мы возвращаем объект, а не класс, для того, чтобы работать с одним источником данных по всему проекту, если вам нужно несколько объектов, вы можете импортировать сам класс KurtubaStorage и создавать на основе его сколько угодно объектов.

Создаем токен:

onlineStorage.create()

Надо сказать, что onlineStorage асинхронный метод, как практически все методы объекта onlineStorage, поэтому лучшим вариантом будет использовать синтаксис async/await.

После создания токена, он записывается в свойство token и далее подставляется при необходимости, например запись данных:

await onlineStorage.set({
  name: 'hazratgs',
  age: 25,
  city: 'Derbent'
  skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})

чтение данных:

const order = await onlineStorage.get('name') // hazratgs

удаление свойства:

await onlineStorage.remove('name')

Теперь можно смело сказать, что у нас есть онлайн имплементация localStorage и даже больше, так localStorage работает только со строками, а наше хранилище работает умеет хранить строки, числа, объекты и логические типы.

Заключение

В результате мы имеем хранилище данных, с резервным копированием, доступом с определенных доменов и парольной защитой, но хочется сказать, что данное хранилище так же как и localStorage не безопасна и не предназначена для хранения основных данных приложения от которых зависит работоспособность проекта, данных пользователя и многое другое, что может навредить вашему приложению.


Используйте её только для малозначимых и общедоступных данных, как в примере у меня, для передачи данных тестовой покупки из окна в iframe.

Проблемы могут возникнуть по причине того, что токен, как и весь javascript мы передаем клиенту и ему не составит труда получить данные всего хранилища, это не проблема конкретно нашего хранилища, любые api-key переданные клиенту, становятся публичными и хотя у нас есть некие защиты в лице работы с определенными доменами и паролями, все это скорее всего можно обойти.

Для того, чтобы спрятать токен, конечно можно написать обертку над api у себя на сервере, но это уже такое, легче уже свою базу данных настроить.

Просьба не ругать сильно, это моя первая публикация и вклад в open source.
Очень буду благодарен в помощи устранения уязвимостей, советам, pull request’ам.

Автор: Хазрат Гаджикеримов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js