Веб-приложения создают с использованием клиент-серверной архитектуры, применяя в качестве коммуникационного протокола HTTP. HTTP — это протокол без сохранения состояния. Каждый раз, когда браузер отправляет серверу запрос, сервер обрабатывает этот запрос независимо от других запросов и не связывает его с предыдущими или последующими запросами того же самого браузера. Это, кроме прочего, означает, что получить доступ к серверным ресурсам, которые никак не защищены, может кто угодно. Если нужно защитить от посторонних некие серверные ресурсы, это значит, что нужно как-то ограничить то, что может запрашивать у сервера браузер. То есть — нужно аутентифицировать запросы и отвечать только на те из них, которые прошли проверку, игнорируя те, которые проверку не прошли. Для аутентификации запросов нужно владеть некими сведениями о запросах, хранящимися на стороне браузера. Так как протокол HTTP не хранит состояние запросов, нам для этого нужны некие дополнительные механизмы, которые позволяют серверу и браузеру совместно управлять состоянием соединений. Среди таких механизмов можно отметить использование куки-файлов, сессий, JWT.
Если речь идёт о каком-то одном веб-проекте, то сведения о состоянии конкретного сеанса взаимодействия клиента и сервера легко поддерживать с применением аутентификации пользователя при его входе в систему. Но если такая вот самостоятельная система эволюционирует, превращаясь в несколько систем, перед разработчиком встаёт вопрос о поддержании сведений о состоянии каждой из этих отдельных систем. На практике этот вопрос выглядит так: «Придётся ли пользователю этих систем входить в каждую из них по-отдельности и так же из них выходить?».
Есть одно хорошее правило, касающееся систем, сложность которых со временем растёт, и взаимодействия этих систем с их пользователями. А именно, нагрузка по решению задач, связанных с усложнением архитектуры проекта, ложится на систему, а не на её пользователей. При этом неважно то, насколько сложны внутренние механизмы веб-проекта. Для пользователя он должен выглядеть единой системой. Иными словами, пользователь, работающий с веб-системой, состоящей из множества компонентов, должен воспринимать происходящее так, будто он работает с одной системой. В частности, речь идёт об аутентификации в таких системах с использованием SSO (Single Sign-On) — технологии единого входа.
Как создавать системы, в которых используется SSO? Тут можно вспомнить старое доброе решение, основанное на куки-файлах, но это решение подвержено ограничениям. Ограничения касаются доменов, с которых устанавливаются куки. Обойти его можно, лишь собрав все доменные имена всех подсистем веб-приложения на одном домене верхнего уровня.
В современных условиях таким решениям препятствует широкое распространение микросервисных архитектур. Управление сессиями усложнилось в тот момент, когда при разработке веб-проектов стали использовать различные технологии, и когда разные службы иногда размещались на разных доменах. Кроме того, веб-службы, которые раньше писали на Java, начали писать, пользуясь возможностями платформы Node.js. Это усложнило работу с куки-файлами. Оказалось, что сессиями теперь управлять не так уж и просто.
Эти сложности привели к разработке новых методов входа в системы, в частности, речь идёт о технологии единого входа.
Технология единого входа
Базовый принцип, на котором основана технология единого входа, заключается в том, что пользователь может войти в одну систему проекта, состоящего из множества систем, и оказаться авторизованным и во всех остальных системах без необходимости повторного входа в них. При этом речь идёт и о централизованном выходе из всех систем.
Мы, в учебных целях, собираемся реализовать технологию SSO на платформе Node.js.
Надо отметить, что реализация этой технологии в корпоративных масштабах потребует гораздо больших усилий, чем мы собираемся приложить к разработке нашей учебной системы. Именно поэтому и существуют специализированные SSO-решения, предназначенные для крупномасштабных проектов.
Как организован вход в систему с использованием SSO?
В сердце реализации SSO находится единственный независимый сервер аутентификации, который способен принимать информацию, позволяющую аутентифицировать пользователей. Например — адрес электронной почты, имя пользователя, пароль. Другие системы не дают пользователю прямых механизмов входа в них. Они авторизуют пользователя непрямым способом, получая сведения о нём от сервера аутентификации. Механизмы непрямой авторизации реализуются с использованием токенов.
Вот репозиторий с кодом проекта simple-sso, реализацию которого я здесь опишу. Я использую платформу Node.js, но вы можете реализовать то же самое и используя что-то другое. Давайте пошагово разберём действия пользователя, работающего с системой, и механизмы, из которых состоит эта система
Шаг 1
Пользователь пытается получить доступ к защищённому ресурсу системы (назовём этот ресурс «потребителем SSO», «sso-consumer»). Потребитель SSO выясняет то, что пользователь не вошёл в систему, и перенаправляет пользователя на «сервер SSO» («sso-server»), используя, в качестве параметра запроса, собственный адрес. На этот адрес будет перенаправлен пользователь, успешно прошедший проверку. Этот механизм представлен ПО промежуточного слоя для Express:
const isAuthenticated = (req, res, next) => {
// простая проверка того, аутентифицирован ли пользователь,
// если это не так - нужно перенаправить пользователя на SSO-сервер для входа в систему и
// передать серверу текущий URL как URL, на который должен быть перенаправлен
// пользователь, успешно прошедший проверку
const redirectURL = `${req.protocol}://${req.headers.host}${req.path}`;
if (req.session.user == null) {
return res.redirect(
`http://sso.ankuranand.com:3010/simplesso/login?serviceURL=${redirectURL}`
);
}
next();
};
module.exports = isAuthenticated;
Шаг 2
SSO-сервер выясняет то, что пользователь в систему не вошёл, и перенаправляет его на страницу входа в систему:
const login = (req, res, next) => {
// В req.query будет url, на который надо будет перенаправить пользователя
//после успешного входа в систему, туда же надо передать sso-токен.
// Эти данные о перенаправлении пользователя ещё можно использовать
// для проверки источника поступления запроса
const { serviceURL } = req.query;
// Попытка прямого доступа приведёт к ошибке в новом URL.
if (serviceURL != null) {
const url = new URL(serviceURL);
if (alloweOrigin[url.origin] !== true) {
return res
.status(400)
.json({ message: "Your are not allowed to access the sso-server" });
}
}
if (req.session.user != null && serviceURL == null) {
return res.redirect("/");
}
// если сведения о пользователе уже имеются в глобальной сессии - перенаправить
// пользователя с токеном
if (req.session.user != null && serviceURL != null) {
const url = new URL(serviceURL);
const intrmid = encodedId();
storeApplicationInCache(url.origin, req.session.user, intrmid);
return res.redirect(`${serviceURL}?ssoToken=${intrmid}`);
}
return res.render("login", {
title: "SSO-Server | Login"
});
};
Сделаю тут некоторые комментарии относительно безопасности.
Мы проверяем serviceURL
, поступающий в виде параметра запроса к SSO-серверу. Благодаря этому мы узнаём о том, зарегистрирован ли этот URL в системе, и о том, может ли представленная им служба пользоваться услугами SSO-сервера.
Вот как может выглядеть список URL служб, которым разрешено использование SSO-сервера:
const alloweOrigin = {
"http://consumer.ankuranand.in:3020": true,
"http://consumertwo.ankuranand.in:3030": true,
"http://test.tangledvibes.com:3080": true,
"http://blog.tangledvibes.com:3080": fasle,
};
Шаг 3
Пользователь вводит имя пользователя и пароль, которые отправляются SSO-серверу в запросе на вход в систему.
Страница входа в систему
Шаг 4
SSO-сервер аутентификации проверяет информацию пользователя и создаёт сессию между собой и пользователем. Это — так называемая «глобальная сессия». Тут же создаётся и токен авторизации. Токен представляет собой строку, состоящую из случайных символов. То, как именно генерируется эта строка, значения не имеет. Главное — это чтобы подобные строки у разных пользователей не повторялись, и чтобы такую строку сложно было бы подделать.
Шаг 5
SSO-сервер берёт токен авторизации и передаёт его туда, откуда к нему пришёл только что авторизовавшийся пользователь (то есть — передаёт токен потребителю SSO).
const doLogin = (req, res, next) => {
// Выполнить проверку с использованием адреса электронной почты и пароля.
// Тут мы не вдаёмся в подробности использования хранилищ данных, поэтому
// userDB - это обычный объект, описанный тут же, в коде сервера
const { email, password } = req.body;
if (!(userDB[email] && password === userDB[email].password)) {
return res.status(404).json({ message: "Invalid email and password" });
}
// В противном случае выполнить перенаправление
const { serviceURL } = req.query;
const id = encodedId();
req.session.user = id;
sessionUser[id] = email;
if (serviceURL == null) {
return res.redirect("/");
}
const url = new URL(serviceURL);
const intrmid = encodedId();
storeApplicationInCache(url.origin, id, intrmid);
return res.redirect(`${serviceURL}?ssoToken=${intrmid}`);
};
Снова сделаю некоторые замечания о безопасности:
- Этот токен нужно всегда рассматривать в роли промежуточного механизма, он используется для получения другого токена.
- Если вы используете JWT в роли промежуточного токена, постарайтесь не включать в его состав секретные данные.
Шаг 6
Потребитель SSO получает токен и обращается к серверу SSO для проверки токена. Сервер проверяет токен и возвращает ещё один токен с информацией о пользователе. Этот токен используется потребителем SSO для создания сессии с пользователем. Эта сессия называется локальной.
Вот код ПО промежуточного слоя, используемого в потребителе SSO, построенном на основе Express:
const ssoRedirect = () => {
return async function(req, res, next) {
// проверяется, есть ли в req queryParameter, представляющий ssoToken,
// и то, что именно является реферером.
const { ssoToken } = req.query;
if (ssoToken != null) {
// для удаления ssoToken в параметре запроса, задающем перенаправление.
const redirectURL = url.parse(req.url).pathname;
try {
const response = await axios.get(
`${ssoServerJWTURL}?ssoToken=${ssoToken}`,
{
headers: {
Authorization: "Bearer l1Q7zkOL59cRqWBkQ12ZiGVW2DBL"
}
}
);
const { token } = response.data;
const decoded = await verifyJwtToken(token);
// теперь у нас есть декодированный jwt, поэтому используем
// global-session-id как id сессии, что позволит
// реализовать процедуру выхода из системы с использованием глобальной сессии.
req.session.user = decoded;
} catch (err) {
return next(err);
}
return res.redirect(`${redirectURL}`);
}
return next();
};
};
После получения запроса от потребителя SSO сервер проверяет токен на предмет его существования и срока его действия. Токен, прошедший проверку, считается действительным.
В нашем случае сервер SSO, после успешной проверки токена, возвращает подписанный JWT с информацией о пользователе.
const verifySsoToken = async (req, res, next) => {
const appToken = appTokenFromRequest(req);
const { ssoToken } = req.query;
// Если нет токена приложения или запрос на ssoToken недействителен.
// Усли ssoToken отсутствует в кеше - значит, нас пытаются обмануть.
if (
appToken == null ||
ssoToken == null ||
intrmTokenCache[ssoToken] == null
) {
return res.status(400).json({ message: "badRequest" });
}
// Если appToken присутствует - проверяем его действительность для приложения
const appName = intrmTokenCache[ssoToken][1];
const globalSessionToken = intrmTokenCache[ssoToken][0];
// Если appToken не соответствует токену, выданному выданному SSO-приложению при регистрации или на более поздней стадии работы
if (
appToken !== appTokenDB[appName] ||
sessionApp[globalSessionToken][appName] !== true
) {
return res.status(403).json({ message: "Unauthorized" });
}
// проверяем, был ли сгенерирован переданный токен
const payload = generatePayload(ssoToken);
const token = await genJwtToken(payload);
// удаляем из кеша ключ, который больше использоваться не будет
delete intrmTokenCache[ssoToken];
return res.status(200).json({ token });
};
Вот некоторые замечания о безопасности.
- На SSO-сервере нужно зарегистрировать все приложения, которые будут использовать этот сервер для аутентификации. Им нужно назначить коды, которые будут использовать для их верификации при выполнении ими запросов к серверу. Это позволяет добиться более высокого уровня безопасности при организации взаимодействия сервера SSO и потребителей SSO.
- Можно сгенерировать различные «приватные» и «публичные» rsa-файлы для каждого приложения и позволить каждому из них верифицировать своими силами их JWT с помощью соответствующих публичных ключей.
Кроме того, можно определить политику безопасности уровня приложения и организовать её централизованное хранение:
const userDB = {
"info@ankuranand.com": {
password: "test",
userId: encodedId(), // в том случае, если вы не хотите передавать адрес электронной почты пользователя.
appPolicy: {
sso_consumer: { role: "admin", shareEmail: true },
simple_sso_consumer: { role: "user", shareEmail: false }
}
}
};
После того, как пользователь успешно войдёт в систему, создаются сессии между ним и SSO-сервером, а так же между ним и каждой подсистемой. Сессия, установленная между пользователем и SSO-сервером, называется глобальной сессией. Сессия, установленная между пользователем и подсистемой, предоставляющей пользователю какие-то услуги, называется локальной сессией. После того, как будет установлена локальная сессия, пользователь сможет работать с закрытыми для посторонних ресурсами подсистемы.
Установка локальной и глобальной сессий
Краткий обзор потребителя SSO и сервера SSO
Давайте сделаем краткий обзор функционала потребителя SSO и сервера SSO.
▍Потребитель SSO
- Подсистема-потребитель SSO не выполняет аутентификацию пользователя, перенаправляя пользователя на сервер SSO.
- Эта подсистема получает токен, передаваемый ей сервером SSO.
- Она взаимодействует с сервером, проверяя действительность токена.
- Она получает JWT и проверяет этот токен с использованием публичного ключа.
- Эта подсистема устанавливает локальную сессию.
▍Сервер SSO
- Сервер SSO проверяет данные, вводимые пользователем для входа в систему.
- Сервер создаёт глобальную сессию.
- Он создаёт токен авторизации.
- Токен авторизации отправляется потребителю SSO.
- Сервер проверяет действительность токенов, передаваемых ему потребителями SSO.
- Сервер отправляет потребителю SSO JWT с информацией о пользователе.
Организация централизованного выхода из системы
Аналогично тому, как была реализована технология единого входа, можно реализовать и «технологию единого выхода». Здесь нужно лишь учитывать следующие соображения:
- Если существует локальная сессия — обязательно существует и глобальная сессия.
- Если существует глобальная сессия, это необязательно означает существование локальной сессии.
- Если локальная сессия уничтожается — должна быть уничтожена и глобальная сессия.
Итоги
В итоге можно отметить, что существует множество готовых реализаций технологии единого входа, которые можно интегрировать в свою систему. У всех них есть собственные преимущества и недостатки. Разработка подобной системы самостоятельно, с нуля, это — итеративный процесс, в ходе которого нужно анализировать характеристики каждой из систем. Сюда входят способы входа в систему, хранилища пользовательской информации, синхронизация данных и многое другое.
Используются ли в ваших проектах механизмы SSO?
Автор: ru_vds