[Перевод] Реализация технологии SSO на базе Node.js

Веб-приложения создают с использованием клиент-серверной архитектуры, применяя в качестве коммуникационного протокола HTTP. HTTP — это протокол без сохранения состояния. Каждый раз, когда браузер отправляет серверу запрос, сервер обрабатывает этот запрос независимо от других запросов и не связывает его с предыдущими или последующими запросами того же самого браузера. Это, кроме прочего, означает, что получить доступ к серверным ресурсам, которые никак не защищены, может кто угодно. Если нужно защитить от посторонних некие серверные ресурсы, это значит, что нужно как-то ограничить то, что может запрашивать у сервера браузер. То есть — нужно аутентифицировать запросы и отвечать только на те из них, которые прошли проверку, игнорируя те, которые проверку не прошли. Для аутентификации запросов нужно владеть некими сведениями о запросах, хранящимися на стороне браузера. Так как протокол HTTP не хранит состояние запросов, нам для этого нужны некие дополнительные механизмы, которые позволяют серверу и браузеру совместно управлять состоянием соединений. Среди таких механизмов можно отметить использование куки-файлов, сессий, JWT.

bao7ltza-atmp2jobx2lk0mxlf0.jpeg

Если речь идёт о каком-то одном веб-проекте, то сведения о состоянии конкретного сеанса взаимодействия клиента и сервера легко поддерживать с применением аутентификации пользователя при его входе в систему. Но если такая вот самостоятельная система эволюционирует, превращаясь в несколько систем, перед разработчиком встаёт вопрос о поддержании сведений о состоянии каждой из этих отдельных систем. На практике этот вопрос выглядит так: «Придётся ли пользователю этих систем входить в каждую из них по-отдельности и так же из них выходить?».
Есть одно хорошее правило, касающееся систем, сложность которых со временем растёт, и взаимодействия этих систем с их пользователями. А именно, нагрузка по решению задач, связанных с усложнением архитектуры проекта, ложится на систему, а не на её пользователей. При этом неважно то, насколько сложны внутренние механизмы веб-проекта. Для пользователя он должен выглядеть единой системой. Иными словами, пользователь, работающий с веб-системой, состоящей из множества компонентов, должен воспринимать происходящее так, будто он работает с одной системой. В частности, речь идёт об аутентификации в таких системах с использованием 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-серверу в запросе на вход в систему.

2563f9783de7f1e207c93afd5e161b0c.jpg


Страница входа в систему

Шаг 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-сервером, называется глобальной сессией. Сессия, установленная между пользователем и подсистемой, предоставляющей пользователю какие-то услуги, называется локальной сессией. После того, как будет установлена локальная сессия, пользователь сможет работать с закрытыми для посторонних ресурсами подсистемы.

01931589cd054d4d4b4f9eea5c710a03.gif
Установка локальной и глобальной сессий

Краткий обзор потребителя SSO и сервера SSO


Давайте сделаем краткий обзор функционала потребителя SSO и сервера SSO.

▍Потребитель SSO


  1. Подсистема-потребитель SSO не выполняет аутентификацию пользователя, перенаправляя пользователя на сервер SSO.
  2. Эта подсистема получает токен, передаваемый ей сервером SSO.
  3. Она взаимодействует с сервером, проверяя действительность токена.
  4. Она получает JWT и проверяет этот токен с использованием публичного ключа.
  5. Эта подсистема устанавливает локальную сессию.


▍Сервер SSO


  1. Сервер SSO проверяет данные, вводимые пользователем для входа в систему.
  2. Сервер создаёт глобальную сессию.
  3. Он создаёт токен авторизации.
  4. Токен авторизации отправляется потребителю SSO.
  5. Сервер проверяет действительность токенов, передаваемых ему потребителями SSO.
  6. Сервер отправляет потребителю SSO JWT с информацией о пользователе.


Организация централизованного выхода из системы


Аналогично тому, как была реализована технология единого входа, можно реализовать и «технологию единого выхода». Здесь нужно лишь учитывать следующие соображения:

  1. Если существует локальная сессия — обязательно существует и глобальная сессия.
  2. Если существует глобальная сессия, это необязательно означает существование локальной сессии.
  3. Если локальная сессия уничтожается — должна быть уничтожена и локальная сессия.


Итоги


В итоге можно отметить, что существует множество готовых реализаций технологии единого входа, которые можно интегрировать в свою систему. У всех них есть собственные преимущества и недостатки. Разработка подобной системы самостоятельно, с нуля, это — итеративный процесс, в ходе которого нужно анализировать характеристики каждой из систем. Сюда входят способы входа в систему, хранилища пользовательской информации, синхронизация данных и многое другое.

Используются ли в ваших проектах механизмы SSO?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru