Для разработки приложений фреймворка Meteor существует некоторое число приемов и средств, предназначенных для обеспечения безопасности. В первой части мы поговорим о более известных вещах — скрытии серверной части кода, пакетах autopublish / insecure, скрытии полей коллекций при публикации и встроенной системе учетных записей, заглянув внутрь коллекции Meteor.users. Во второй — про loginToken, выдаваемый клиенту, правила allow/deny при модификации базы данных клиентом, доверенном и недоверенном коде, серверных методах, HTTPS, пакете force-ssl и пакете browser-policy (Content Security Policy и X-Frame-Options), встроенном механизме валидации данных (функция check() и пакет audit-arguments-check).
Все, описанное ниже, применимо к текущей версии Meteor 0.7.0.1 с установленным Meteorite. Кстати, если примеры этой статьи не будут работать в более новой версии Meteor, с помощью Meteorite примеры этой статьи также можно будет посмотреть, указав при запуске ключ --release:
Начнем, пожалуй, с самого простого — разнесения клиентской и серверной частей кода. Хотя Meteor допускает совместное использование кода клиентом и сервером, делать так имеет смысл только при реальной необходимости. Кроме структурирования самого проекта, которое так или иначе понадобится по мере роста его объема, и сокращения объема данных, передаваемых клиенту, это позволяет скрыть код серверной части.
Подкаталоги server, client, client/compatibility, public, private, lib, tests (и файлы main.*) обрабатываются особым образом. Достаточно подробно это описано в документации: docs.meteor.com/#structuringyourapp. Для кода, выполняемого только на сервере предназначен каталог server, файлы из которого не передаются клиенту. Аналогичным образом, код из файлов в каталоге client не загружается на сервер.
Некоторая часть кода, как, например, объявления коллекций, должна быть доступна и клиенту и серверу. Такой код можно размещать в подкаталогах с любыми названиями, отличными от зарезервированных (также можно его разместить в lib, разница в том, что файлы из этого каталога загружаются раньше других).
В рамках этого примера мы будем размещать объявления коллекций в подкаталоге collections.
Создадим новое приложение:
И удалим автоматически созданные файлы:
Создадим подкаталоги server, client, collections. В collections добавим файл test.js с объявлением тестовой коллекции:
Собственно, проект уже можно запустить:
И открыть в браузере: localhost:3000
Мы увидим только пустую страницу, но объект Test, пусть тоже пустой, уже будет доступен из консоли:
Добавим файл home.html в подкаталог client:
И home.js:
В подкаталог server — файл startup.js:
Теперь тестовая коллекция доступна как клиенту, так и серверу, причем код сервера клиенту не передается. Поле _id — идентификатор базы данных, автоматически генерируемые при вставке новой записи.
На данный момент коллекция доступна абсолютно всем как на чтение, так и на запись, что можно проверить из консоли:
И:
Убираем пакеты autopublish / insecure
Полный доступ к объявленной нами коллекции мы получили благодаря подключенным по умолчанию к проекту при создании пакетам autopublish и insecure. Первый из них «публикует» все существующие на сервере коллекции, второй — дает полные права на их изменения. Это существенно облегчает знакомство с Meteor и быстрое прототипирование, но, разумеется, неприемлемо в реальной жизни, поэтому:
Объект Test в браузере по-прежнему доступен, однако данных больше нет:
Теперь, чтобы в штатном режиме, без autopublish, клиент получил доступ к серверным данным, сервер их должен опубликовать, а клиент — подписаться.
Добавим в файл server/startup.js публикацию коллекции (ее можно поместить как вне функции, добавляемой Meteor.startup(), так и внутри неё; разница будет в моменте создания публикации — важно, чтобы к этому времени публикуемая коллекция уже существовала):
Если коллекцию опубликовать в то время, как пакет autopublish еще подключен к проекту, при старте Meteor будет выведено предупреждение об этом:
В файл client/home.js добавим соответствующую подписку:
Теперь данные в браузере опять появились, однако прав на их изменение больше нет:
Обратите внимание, что запись сначала вставляется в локальную коллекцию, возвращается ее идентификатор («BDgB258TovmqS7YbY»), и эта запись отображается в браузере, затем с сервера приходит уведомление об ошибке, и вставка в локальную коллекцию «откатывается». Отображение в браузере происходит благодаря механизму компенсации задержек, а ошибка появляется из-за проверки правил allow/deny (о них — чуть позже); без пакета insecure изменения по умолчанию запрещены.
Скрытие полей при публикации
Коллекцию не обязательно публиковать полностью. До сих пор отображались все поля из серверной коллекции. Можно, например, скрыть поле _id, это ограничит возможность манипуляции данными, так как со стороны клиента для изменении данных необходимо указывать идентификатор записи (см.также ограничение изменения данных по_id), либо другие поля, как, например, это реализовано в коллекции Meteor.users (см.ниже):
Скрыть поле _id таким образом, к сожалению, не получится — вероятно, оно используется для установления соответствия между серверной и локальной коллекциями. В текущей версии это приведет к ошибке, раньше просто возвращало пустую коллекцию.
Разумеется, на объеме передаваемых данных скрытие полей также скажется положительно.
Все сказанное выше применимо к неавторизованным пользователям. А какие возможности появляются, если пользователь авторизован?
Добавим к проекту поддержку системы учетных записей Meteor:
Кроме accounts-google есть официальные пакеты для целого ряда провайдеров авторизации, в их числе Facebook, Twitter, Github и другие. Неофициальными пакетами поддерживаются и другие провайдеры, например, Vkontakte.
Используем в home.html готовый шаблон кнопки авторизации пользователя:
И currentUser для отображения информации о пользователе:
И, воспользовавшись пошаговой инструкцией, которая будет выведена при первом нажатии на кнопку, зарегистрируем свое приложение в Google, после чего немедленно сможем авторизовать пользователя.
Добавим пакет для поддержки Facebook:
Затем зарегистрируем второго пользователя.
Учетные записи хранятся в коллекции Meteor.users, но если в браузере мы обратимся к ней, увидим только один документ, хотя пользователей зарегистрировано уже два:
Дело в том, что по умолчанию (если отключен пакет autopublish — с ним все немного сложнее) у клиента есть доступ только к части полей своего собственного документа этойколлекции:
Этот же документ доступен через переменную Meteor.user, а его идентификатор — Meteor.userId()
Есть и соответствующий функция (helper) Handlebars currentUser, с помощью которого мы выводим информацию о текущем пользователе.
Если заглянуть в исходники пакета accounts-base, коллекция Meteor.users создается на сервере следующим образом:
И публикуется так:
Если пользователь авторизован, this.userId содержит его идентификатор, по которому из базы возвращается поддокумент profile, поле username и поддокумент emails (последние два не заполняются по умолчанию). В противном случае никакие данные не публикуются. Это значит, что авторизованному пользователю по умолчанию доступна только часть полей и только его собственного документа из базы.
Ссылка на изображение пользователя Google из социальной сети хранится в поддокументе services.google.picture, а получить доступ к изображению из Facebook можно, используя идентификатор services.facebook.id. Чтобы получить к ним доступ, их необходимо дополнительно опубликовать, например, так (добавим в server/startup.js):
И ссылку на картинку — в home.html:
Выбранные поля теперь доступны клиенту:
Вместо того, чтобы явно указывать поля, можно было опубликовать весь документ пользователя, задав projection = {} или поддокумент services, задав projection = { services: 1 }. Однако в этом случае клиент получит явно лишние для него данные, такие как accessToken или loginTokens.
При необходимости отображать информацию о других пользователях, можно опубликовать избранные поля всех документов коллекции, например, добавив к существующим публикациям еще одну:
Результатом будет объединение всех публикаций, и теперь всем пользователям, в том числе неавторизованным, доступны имена всех зарегистрированных пользователей, а текущему пользователю — дополнительные поля его документа:
Все, описанное ниже, применимо к текущей версии Meteor 0.7.0.1 с установленным Meteorite. Кстати, если примеры этой статьи не будут работать в более новой версии Meteor, с помощью Meteorite примеры этой статьи также можно будет посмотреть, указав при запуске ключ --release:
$ mrt --release 0.7.0.1
Скрытие серверной части кода
Начнем, пожалуй, с самого простого — разнесения клиентской и серверной частей кода. Хотя Meteor допускает совместное использование кода клиентом и сервером, делать так имеет смысл только при реальной необходимости. Кроме структурирования самого проекта, которое так или иначе понадобится по мере роста его объема, и сокращения объема данных, передаваемых клиенту, это позволяет скрыть код серверной части.
Подкаталоги server, client, client/compatibility, public, private, lib, tests (и файлы main.*) обрабатываются особым образом. Достаточно подробно это описано в документации: docs.meteor.com/#structuringyourapp. Для кода, выполняемого только на сервере предназначен каталог server, файлы из которого не передаются клиенту. Аналогичным образом, код из файлов в каталоге client не загружается на сервер.
Некоторая часть кода, как, например, объявления коллекций, должна быть доступна и клиенту и серверу. Такой код можно размещать в подкаталогах с любыми названиями, отличными от зарезервированных (также можно его разместить в lib, разница в том, что файлы из этого каталога загружаются раньше других).
В рамках этого примера мы будем размещать объявления коллекций в подкаталоге collections.
Создадим новое приложение:
$ mrt create littlesec
И удалим автоматически созданные файлы:
$ cd littlesec
$ rm littlesec.*
Создадим подкаталоги server, client, collections. В collections добавим файл test.js с объявлением тестовой коллекции:
Test = new Meteor.Collection('test');
Собственно, проект уже можно запустить:
$ mrt
И открыть в браузере: localhost:3000
Мы увидим только пустую страницу, но объект Test, пусть тоже пустой, уже будет доступен из консоли:
> Test.find().count()
0
Добавим файл home.html в подкаталог client:
<head>
<title>littlesec</title>
</head>
<body>
{{> home}}
</body>
<template name="home">
{{#each test}}
<li>
<ul>_id:'{{_id}}' name:'{{name}}' value:'{{value}}'</ul>
</li>
{{else}}
<p>Collection Test is empty</p>
{{/each}}
</template>
И home.js:
Template.home.test = function() {
return Test.find({});
}
В подкаталог server — файл startup.js:
Meteor.startup(function(){
if (!Test.find({}).count()) {
var testValues = [
{name: 'First', value: 1},
{name: 'Second', value: 2},
{name: 'Third', value: 3}
];
testValues.forEach( function(testValue) {
Test.insert(testValue);
});
}
});
Теперь тестовая коллекция доступна как клиенту, так и серверу, причем код сервера клиенту не передается. Поле _id — идентификатор базы данных, автоматически генерируемые при вставке новой записи.
На данный момент коллекция доступна абсолютно всем как на чтение, так и на запись, что можно проверить из консоли:
> Test.findOne({})
И:
> Test.insert({name: 'Fourth', value: 4})
Убираем пакеты autopublish / insecure
Полный доступ к объявленной нами коллекции мы получили благодаря подключенным по умолчанию к проекту при создании пакетам autopublish и insecure. Первый из них «публикует» все существующие на сервере коллекции, второй — дает полные права на их изменения. Это существенно облегчает знакомство с Meteor и быстрое прототипирование, но, разумеется, неприемлемо в реальной жизни, поэтому:
$ mrt remove autopublish
$ mrt remove insecure
Объект Test в браузере по-прежнему доступен, однако данных больше нет:
> Test.find().count()
0
Теперь, чтобы в штатном режиме, без autopublish, клиент получил доступ к серверным данным, сервер их должен опубликовать, а клиент — подписаться.
Добавим в файл server/startup.js публикацию коллекции (ее можно поместить как вне функции, добавляемой Meteor.startup(), так и внутри неё; разница будет в моменте создания публикации — важно, чтобы к этому времени публикуемая коллекция уже существовала):
Meteor.publish('test', function() {
return Test.find();
}
);
Если коллекцию опубликовать в то время, как пакет autopublish еще подключен к проекту, при старте Meteor будет выведено предупреждение об этом:
I20140131-11:36:07.343(4)? ** You've set up some data subscriptions with Meteor.publish(), but
I20140131-11:36:07.388(4)? ** you still have autopublish turned on. <..>
В файл client/home.js добавим соответствующую подписку:
Meteor.subscribe('test');
Теперь данные в браузере опять появились, однако прав на их изменение больше нет:
> Test.insert({name: 'Fifth', value: 5})
"BDgB258TovmqS7YbY"
insert failed: Access denied
Обратите внимание, что запись сначала вставляется в локальную коллекцию, возвращается ее идентификатор («BDgB258TovmqS7YbY»), и эта запись отображается в браузере, затем с сервера приходит уведомление об ошибке, и вставка в локальную коллекцию «откатывается». Отображение в браузере происходит благодаря механизму компенсации задержек, а ошибка появляется из-за проверки правил allow/deny (о них — чуть позже); без пакета insecure изменения по умолчанию запрещены.
Скрытие полей при публикации
Коллекцию не обязательно публиковать полностью. До сих пор отображались все поля из серверной коллекции. Можно, например, скрыть поле _id, это ограничит возможность манипуляции данными, так как со стороны клиента для изменении данных необходимо указывать идентификатор записи (см.также ограничение изменения данных по_id), либо другие поля, как, например, это реализовано в коллекции Meteor.users (см.ниже):
Meteor.publish('test', function() {
var projection = {_id: 0, value: 1};
return Test.find({}, {fields: projection} );
// return Test.find();
}
);
Скрыть поле _id таким образом, к сожалению, не получится — вероятно, оно используется для установления соответствия между серверной и локальной коллекциями. В текущей версии это приведет к ошибке, раньше просто возвращало пустую коллекцию.
Разумеется, на объеме передаваемых данных скрытие полей также скажется положительно.
Система учетных записей Meteor
Все сказанное выше применимо к неавторизованным пользователям. А какие возможности появляются, если пользователь авторизован?
Добавим к проекту поддержку системы учетных записей Meteor:
$ mrt add accounts-base
$ mrt add accounts-ui
$ mrt add accounts-google
Кроме accounts-google есть официальные пакеты для целого ряда провайдеров авторизации, в их числе Facebook, Twitter, Github и другие. Неофициальными пакетами поддерживаются и другие провайдеры, например, Vkontakte.
Используем в home.html готовый шаблон кнопки авторизации пользователя:
{{loginButtons}}
И currentUser для отображения информации о пользователе:
{{currentUser._id}}
{{currentUser.profile.name}}
И, воспользовавшись пошаговой инструкцией, которая будет выведена при первом нажатии на кнопку, зарегистрируем свое приложение в Google, после чего немедленно сможем авторизовать пользователя.
Добавим пакет для поддержки Facebook:
$ mrt add accounts-facebook
Затем зарегистрируем второго пользователя.
Учетные записи хранятся в коллекции Meteor.users, но если в браузере мы обратимся к ней, увидим только один документ, хотя пользователей зарегистрировано уже два:
> Meteor.users.find().count()
1
Дело в том, что по умолчанию (если отключен пакет autopublish — с ним все немного сложнее) у клиента есть доступ только к части полей своего собственного документа этойколлекции:
> Meteor.users.findOne())
{"_id":"8fLXBYpNGLqDwAahg","profile":{"name":"<Имя пользователя>"}}
Этот же документ доступен через переменную Meteor.user, а его идентификатор — Meteor.userId()
> Meteor.user()
{"_id":"8fLXBYpNGLqDwAahg","profile":{"name":"<Имя пользователя>"}}
Есть и соответствующий функция (helper) Handlebars currentUser, с помощью которого мы выводим информацию о текущем пользователе.
Если заглянуть в исходники пакета accounts-base, коллекция Meteor.users создается на сервере следующим образом:
Meteor.users = new Meteor.Collection("users", {_preventAutopublish: true});
И публикуется так:
// Publish the current user's record to the client.
Meteor.publish(null, function() {
if (this.userId) {
return Meteor.users.find(
{_id: this.userId},
{fields: {profile: 1, username: 1, emails: 1}});
} else {
return null;
}
}, /*suppress autopublish warning*/{is_auto: true});
Если пользователь авторизован, this.userId содержит его идентификатор, по которому из базы возвращается поддокумент profile, поле username и поддокумент emails (последние два не заполняются по умолчанию). В противном случае никакие данные не публикуются. Это значит, что авторизованному пользователю по умолчанию доступна только часть полей и только его собственного документа из базы.
Ссылка на изображение пользователя Google из социальной сети хранится в поддокументе services.google.picture, а получить доступ к изображению из Facebook можно, используя идентификатор services.facebook.id. Чтобы получить к ним доступ, их необходимо дополнительно опубликовать, например, так (добавим в server/startup.js):
Meteor.publish(null, function() {
if (this.userId) {
var projection = {
'services.google.picture': 1,
'services.facebook.id': 1,
'services.vk.photo': 1
};
return Meteor.users.find(
{ _id: this.userId },
{ fields: projection } );
} else {
return null;
}
});
И ссылку на картинку — в home.html:
{{#if currentUser.services.facebook}}
<img src="http://graph.facebook.com/{{currentUser.services.facebook.id}}/picture/?type=square"> <!-- small || normal || large || square -->
{{else}}{{#if currentUser.services.google}}
<img src={{currentUser.services.google.picture}}>
{{else}}{{#if currentUser.services.vk}}
<img src={{currentUser.services.vk.photo}}>
{{/if}}{{/if}}{{/if}}
Выбранные поля теперь доступны клиенту:
> Meteor.users.findOne()
{
"_id":"8fLXBYpNGLqDwAahg",
"profile": {
"name":"<Имя пользователя>"
},
"services": {
"facebook": {
"id": "<Идентификатор пользователя Facebook>"
}
}
}
Вместо того, чтобы явно указывать поля, можно было опубликовать весь документ пользователя, задав projection = {} или поддокумент services, задав projection = { services: 1 }. Однако в этом случае клиент получит явно лишние для него данные, такие как accessToken или loginTokens.
При необходимости отображать информацию о других пользователях, можно опубликовать избранные поля всех документов коллекции, например, добавив к существующим публикациям еще одну:
Meteor.publish(null, function() {
var projection = {
'profile.name': 1
};
return Meteor.users.find(
{ },
{ fields: projection } );
});
Результатом будет объединение всех публикаций, и теперь всем пользователям, в том числе неавторизованным, доступны имена всех зарегистрированных пользователей, а текущему пользователю — дополнительные поля его документа:
Meteor.users.find().fetch()
[
{
"_id":"8fLXBYpNGLqDwAahg",
"profile": {
"name":"<Имя пользователя>"
}
}, {
"_id":"kL7Fkuk29ci4vz8q4",
"profile": {
"name":"<Имя пользователя>"
},
"services": {
"google":{
"picture":"<ссылка на изображение>"
}
}
}
]