Статьи

Несколько приложений пользовательского интерфейса и шлюз: одностраничное приложение с Spring и Angular JS Part VI


Автор Дэйв Сайер в весеннем блоге 

В этой статье мы продолжим  наше обсуждение  того, как использовать  Spring Security  с  Angular JS  в «одностраничном приложении». Здесь мы покажем, как использовать  Spring Session  вместе с  Spring Cloud,  чтобы объединить функции систем, которые мы создали в частях II и IV, и фактически создать 3 одностраничных приложения с совершенно разными обязанностями. Цель состоит в том, чтобы создать шлюз (как в  части IV ), который используется не только для ресурсов API, но и для загрузки пользовательского интерфейса с внутреннего сервера. Мы упростили биты с токенами в  части II используя шлюз для прохождения аутентификации на серверы. Затем мы расширяем систему, чтобы показать, как мы можем принимать локальные, детализированные решения о доступе в бэкэндах, по-прежнему контролируя идентификацию и аутентификацию на шлюзе. Это очень мощная модель для построения распределенных систем в целом, и она обладает рядом преимуществ, которые мы можем изучить, представив функции в коде, который мы создаем.

Напоминание: если вы работаете в этой статье с примером приложения, обязательно очистите кеш браузера от файлов cookie и учетных данных HTTP Basic. В Chrome лучший способ сделать это — открыть новое окно инкогнито.

Целевая Архитектура

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

Компоненты системы

Как и другие примеры приложений из этой серии, он имеет пользовательский интерфейс (HTML и JavaScript) и сервер ресурсов. Как и в примере в  части IV, у  него есть шлюз, но здесь он является отдельным, а не частью пользовательского интерфейса. Пользовательский интерфейс фактически становится частью серверной части, предоставляя нам еще больший выбор для повторной настройки и повторной реализации функций, а также, как мы увидим, другие преимущества.

Браузер отправляется к шлюзу за всем, и ему не нужно знать об архитектуре бэкэнда (по сути, он понятия не имеет, что есть бэкэнд). Браузер в этом шлюзе выполняет аутентификацию, например, отправляет имя пользователя и пароль, как в  части II , и получает взамен cookie. При последующих запросах он представляет файл cookie автоматически, и шлюз передает его бэкэндам. На клиенте не нужно писать код, чтобы разрешить передачу куки. Бэкэнды используют cookie для аутентификации, и поскольку все компоненты совместно используют сеанс, они совместно используют одну и ту же информацию о пользователе. Сравните это с  частью V где файл cookie должен быть преобразован в токен доступа в шлюзе, а затем токен доступа должен быть независимо декодирован всеми компонентами бэкэнда.

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

Исходный код для всего проекта, который мы собираемся создать, находится здесь на  Github , так что вы можете просто клонировать проект и работать напрямую оттуда, если хотите. В конечном состоянии этой системы есть дополнительный компонент («двойной администратор»), поэтому пока проигнорируйте его.

Создание бэкэнда

В этой архитектуре серверная часть очень похожа на   образец «весенней сессии», который мы создали в  третьей части , за исключением того, что на самом деле ему не нужна страница входа. Самый простой способ получить то , что мы хотим здесь, вероятно , чтобы скопировать сервер «ресурс» из части III и принять интерфейс от «базового»  образца в  части I . Чтобы перейти от «базового» пользовательского интерфейса к тому, который нам нужен, нам нужно только добавить пару зависимостей (например, когда мы впервые использовали  Spring Session  в части III):

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
  <version>1.0.0.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

и добавьте  @EnableRedisHttpSession аннотацию к основному классу приложения:

@SpringBootApplication
@EnableRedisHttpSession
public class UiApplication {

public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

}

Поскольку теперь это пользовательский интерфейс, нет необходимости в конечной точке «/ resource». Когда вы это сделаете, у вас будет очень простое приложение на Angular (такое же, как в «базовом» примере), которое значительно упрощает тестирование и анализ его поведения.

Наконец, мы хотим, чтобы этот сервер работал как бэкэнд, поэтому мы дадим ему порт не по умолчанию для прослушивания (in  application.properties):

server.port: 8081
security.sessions: NEVER

Если это  весь  контент,  application.properties то приложение будет безопасным и доступным для пользователя с именем «пользователь» с произвольным паролем, который печатается на консоли (на уровне журнала INFO) при запуске. Параметр «security.sessions» означает, что Spring Security будет принимать файлы cookie в качестве маркеров проверки подлинности, но не будет создавать их, если они еще не существуют.

Ресурсный сервер

Сервер ресурсов легко создать из одного из наших существующих образцов. Он такой же, как сервер ресурсов «весенний сеанс» в  части III : просто конечная точка «/ ресурс» и @EnableRedisHttpSession для получения данных распределенного сеанса. Мы хотим, чтобы этот сервер имел порт не по умолчанию для прослушивания, и мы хотим иметь возможность искать аутентификацию в сеансе, поэтому нам нужно следующее (in  application.properties):

server.port: 9000
security.sessions: NEVER

Готовый образец находится  здесь, в github,  если вы хотите взглянуть.

Шлюз

Для начальной реализации шлюза (самая простая вещь, которая могла бы работать), мы можем просто взять пустое веб-приложение Spring Boot и добавить  @EnableZuulProxy аннотацию. Как мы видели в  первой части,  есть несколько способов сделать это, и один из них — использовать  Spring Initializr  для создания каркасного проекта. Еще проще — использовать  Spring Cloud Initializr,  что то же самое, но для   приложений Spring Cloud . Используя ту же последовательность операций командной строки, что и в части I:

$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
  -d style=security -d style=cloud-zuul -d name=gateway \
  -d style=redis | tar -xzvf - 

Затем вы можете импортировать этот проект (по умолчанию это обычный Java-проект Maven) в вашу любимую среду разработки или просто работать с файлами и «mvn» в командной строке. В github есть версия,   если вы хотите пойти оттуда, но в ней есть несколько дополнительных функций, которые нам пока не нужны.

Начиная с пустого приложения Initializr, мы добавляем зависимость Spring Session (как в пользовательском интерфейсе выше) и  @EnableRedisHttpSession аннотацию:

@SpringBootApplication
@EnableRedisHttpSession
@EnableZuulProxy
public class GatewayApplication {

public static void main(String[] args) {
    SpringApplication.run(GatewayApplication.class, args);
  }

}

Шлюз готов к работе, но он еще не знает о наших серверных службах, поэтому давайте просто настроим его  application.yml (переименование,  application.properties если вы выполнили указание выше):

zuul:
  routes:
    ui:
      url: http://localhost:8081
    resource:
      url: http://locahost:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

В прокси имеется 2 маршрута, по одному для пользовательского интерфейса и сервера ресурсов, и мы установили пароль по умолчанию и стратегию сохранения сеанса (сообщая Spring Security всегда создавать сеанс при аутентификации). Этот последний бит важен, потому что мы хотим, чтобы аутентификация и, следовательно, сеансы управлялись в шлюзе.

Вверх и работает

Теперь у нас есть три компонента, работающие на 3 портах. Если вы указываете браузер по адресу http: // localhost: 8080 / ui /,  вы должны получить вызов HTTP Basic, и вы можете аутентифицироваться как «user / password» (ваши учетные данные в шлюзе), и после этого вы должны увидеть приветствие в пользовательском интерфейсе через внутренний вызов через прокси-сервер к серверу ресурсов.

Взаимодействие между браузером и бэкэндом можно увидеть в вашем браузере, если вы используете некоторые инструменты разработчика (обычно F12 открывает это, работает в Chrome по умолчанию, требует плагина в Firefox). Вот резюме:

ГЛАГОЛА ПУТЬ ПОЛОЖЕНИЕ ДЕЛ ОТВЕТ
ПОЛУЧИТЬ / Щ / 401 Браузер запрашивает аутентификацию
ПОЛУЧИТЬ / Щ / 200 index.html
ПОЛУЧИТЬ /ui/css/angular-bootstrap.css 200 Twitter загружает CSS
ПОЛУЧИТЬ /ui/js/angular-bootstrap.js 200 Бутстрап и угловой JS
ПОЛУЧИТЬ /ui/js/hello.js 200 Логика применения
ПОЛУЧИТЬ / Щ / пользователь 200 аутентификация
ПОЛУЧИТЬ /ресурс/ 200 JSON приветствие

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

Ура, это работает! У вас есть два внутренних сервера, один из которых представляет собой пользовательский интерфейс, каждый с независимыми возможностями и может тестироваться изолированно, и они соединены вместе с безопасным шлюзом, который вы контролируете и для которого вы настроили аутентификацию. Если бэкэнды недоступны для браузера, это не имеет значения (на самом деле это, вероятно, преимущество, потому что дает вам еще больший контроль над физической безопасностью).

Добавление формы входа

Как и в «базовом» примере из  части I,  теперь мы можем добавить форму входа в шлюз, например, скопировав код из  части II . Когда мы сделаем это, мы также можем добавить некоторые базовые элементы навигации в шлюз, чтобы пользователю не нужно было знать путь к бэкенду пользовательского интерфейса в прокси. Итак, давайте сначала скопируем статические ресурсы из «единого» пользовательского интерфейса в шлюз, удалим отображение сообщений и вставим форму входа в систему на нашей домашней странице (  <body/> где-то):

<body ng-app="hello" ng-controller="navigation" ng-cloak
class="ng-cloak">
  ...
  <div class="container" ng-show="!authenticated">
    <form role="form" ng-submit="login()">
      <div class="form-group">
        <label for="username">Username:</label> <input type="text"
          class="form-control" id="username" name="username"
          ng-model="credentials.username" />
      </div>
      <div class="form-group">
        <label for="password">Password:</label> <input type="password"
          class="form-control" id="password" name="password"
          ng-model="credentials.password" />
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </div>
</body>

Вместо отображения сообщения у нас будет хорошая большая кнопка навигации:

<div class="container" ng-show="authenticated">
  <a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>

Если вы смотрите пример в github, он также имеет минимальную панель навигации с кнопкой «Выход». Вот форма входа в систему на скриншоте:

Страница авторизации

Для поддержки формы входа нам нужен JavaScript с контроллером «навигации», реализующим  login() функцию, которую мы объявили в  <form/>, и нам нужно установить authenticated флаг, чтобы домашняя страница отображалась по-разному в зависимости от того, аутентифицирован ли пользователь. Например:

angular.module('hello', []).controller('navigation',
function($scope, $http) {

  ...

  authenticate();

  $scope.credentials = {};

$scope.login = function() {
    authenticate($scope.credentials, function() {
      if ($scope.authenticated) {
        console.log("Login succeeded")
        $scope.error = false;
        $scope.authenticated = true;
      } else {
        console.log("Login failed")
        $scope.error = true;
        $scope.authenticated = false;
      }
    })
  };

}

где реализация  authenticate() функции аналогична  части II :

var authenticate = function(credentials, callback) {

  var headers = credentials ? {
    authorization : "Basic "
        + btoa(credentials.username + ":"
            + credentials.password)
  } : {};

  $http.get('user', {
    headers : headers
  }).success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
    } else {
      $scope.authenticated = false;
    }
    callback && callback();
  }).error(function() {
    $scope.authenticated = false;
    callback && callback();
  });

}

Мы можем использовать   флаг $scope для хранения  authenticatedфлага, потому что в этом простом приложении есть только один контроллер.

Если мы запустим этот улучшенный шлюз, вместо того, чтобы запоминать URL для пользовательского интерфейса, мы можем просто загрузить домашнюю страницу и перейти по ссылкам. Вот домашняя страница для аутентифицированного пользователя:

Домашняя страница

Решения детального доступа в бэкэнде

До сих пор наше приложение функционально очень похоже на приложение в  части III  или  части IV , но с дополнительным выделенным шлюзом. Преимущество дополнительного слоя может быть еще неочевидным, но мы можем подчеркнуть его, немного расширив систему. Предположим, что мы хотим использовать этот шлюз для предоставления другого пользовательского интерфейса бэкэнда, чтобы пользователи могли «администрировать» контент в основном пользовательском интерфейсе, и что мы хотим ограничить доступ к этой функции для пользователей с особыми ролями. Таким образом, мы добавим приложение «Admin» за прокси, и система будет выглядеть так:

Компоненты системы

В шлюзе есть новый компонент (Admin) и новый маршрут  application.yml:

zuul:
  routes:
    ui:
      url: http://localhost:8081
    admin:
      url: http://localhost:8082
    resource:
      url: http://localhost:9000

Тот факт, что существующий пользовательский интерфейс доступен пользователям в роли «ПОЛЬЗОВАТЕЛЬ», указан на блок-схеме выше в поле «Шлюз» (зеленая надпись), а также тот факт, что роль «ADMIN» необходима для перехода в приложение администратора. , Решение о доступе для роли «ADMIN» может быть применено в шлюзе, и в этом случае оно может появиться в  WebSecurityConfigurerAdapter, или оно может быть применено в самом приложении Admin (и мы увидим, как это сделать ниже).

Кроме того, предположим, что в приложении Admin мы хотим различать роли «READER» и «WRITER», чтобы мы могли позволить (скажем) пользователям, которые являются аудиторами, просматривать изменения, сделанные основными пользователями администратора. Это детальное решение о доступе, когда правило известно и должно быть известно только в бэкэнд-приложении. В шлюзе нам нужно только убедиться, что наши учетные записи пользователей имеют необходимые роли, и эта информация доступна, но шлюзу не нужно знать, как его интерпретировать. В шлюзе мы создаем учетные записи пользователей, чтобы сохранить пример приложения:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser("user").password("password").roles("USER")
    .and()
      .withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
    .and()
      .withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
  }

}

где пользователь «admin» был расширен тремя новыми ролями («ADMIN», «READER» и «WRITER»), и мы также добавили пользователя «аудит» с доступом «ADMIN», но не «WRITER».

Кроме того: в производственной системе данные учетной записи пользователя будут обрабатываться в серверной базе данных (скорее всего, в службе каталогов), а не жестко кодироваться в конфигурации Spring. Примеры приложений, подключающихся к такой базе данных, легко найти в Интернете, например, в  Spring Security Samples .

Решения о доступе идут в приложении администратора. Для роли «ADMIN» (которая требуется глобально для этого бэкэнда) мы делаем это в Spring Security:

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    ...
      .authorizeRequests()
        .antMatchers("/index.html", "/login", "/").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    ...
  }

}

Для ролей «READER» и «WRITER» само приложение разделено, и, поскольку приложение реализовано в JavaScript, именно здесь нам необходимо принять решение о доступе. Один из способов сделать это — встроить в нее домашнюю страницу с вычисленным представлением:

<div class="container">
  <h1>Admin</h1>
  <div ng-show="authenticated" ng-include="template"></div>
  <div ng-show="!authenticated" ng-include="'unauthenticated.html'"></div>
</div>

Angular JS оценивает значение атрибута «ng-include» как выражение, а затем использует результат для загрузки шаблона.

Совет: Более сложное приложение может использовать другие механизмы для модульной самореализации, например, $routeProvider сервис, который мы использовали почти во всех других приложениях этой серии.

template Переменная инициализируется в нашем контроллере, во- первых, определив функцию полезности:

var computeDefaultTemplate = function(user) {
  $scope.template = user && user.roles
      && user.roles.indexOf("ROLE_WRITER")>0 ? "write.html" : "read.html";
}

затем с помощью функции полезности, когда контроллер загружает:

angular.module('admin', []).controller('home',

function($scope, $http) {

  $http.get('user').success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
      $scope.user = data;
      computeDefaultTemplate(data);
    } else {
      $scope.authenticated = false;
    }
    $scope.error = null
  })
  ...

})

Первое, что делает приложение, это просматривает обычную (для этой серии) конечную точку «/ user», затем она извлекает некоторые данные, устанавливает флаг аутентификации, и, если пользователь аутентифицирован, вычисляет шаблон, просматривая данные пользователя.

Для поддержки этой функции на сервере нам нужна конечная точка, например, в нашем основном классе приложения:

@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class AdminApplication {

  @RequestMapping("/user")
  public Map<String, Object> user(Principal user) {
    Map<String, Object> map = new LinkedHashMap<String, Object>();
    map.put("name", user.getName());
    map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
        .getAuthorities()));
    return map;
  }

  public static void main(String[] args) {
    SpringApplication.run(AdminApplication.class, args);
  }

}

Примечание. Имена ролей возвращаются из конечной точки «/ user» с префиксом «ROLE_», чтобы мы могли отличать их от других видов полномочий (это Spring Security). Таким образом, префикс «ROLE_» необходим в JavaScript, но не в конфигурации Spring Security, где из имен методов ясно, что «роли» являются фокусом операций.

Почему мы здесь?

Теперь у нас есть симпатичная маленькая система с 2 независимыми пользовательскими интерфейсами и внутренним сервером ресурсов, которые защищены одной и той же аутентификацией в шлюзе. Тот факт, что шлюз выступает в роли микропрокси-сервера, делает реализацию базовых задач безопасности чрезвычайно простой, и они могут свободно концентрироваться на собственных бизнес-задачах. Использование Spring Session (опять же) позволило избежать огромного количества хлопот и потенциальных ошибок.

Мощная особенность заключается в том, что бэкэнды могут независимо иметь любой вид аутентификации, который им нравится (например, вы можете перейти непосредственно к пользовательскому интерфейсу, если вы знаете его физический адрес и набор локальных учетных данных). Шлюз накладывает совершенно не связанный набор ограничений, если он может аутентифицировать пользователей и назначать им метаданные, которые удовлетворяют правилам доступа в бэкэндах. Это отличный дизайн для возможности самостоятельной разработки и тестирования компонентов бэкэнда. Если бы мы захотели, мы могли бы вернуться к внешнему серверу OAuth2 (как в  Части V , или даже к чему-то совершенно другому) для аутентификации на Шлюзе, и к бэкэндам не нужно было прикасаться.

Бонусная особенность этой архитектуры (единый шлюз, контролирующий аутентификацию и совместно используемый токен сеанса для всех компонентов) заключается в том, что функция «Единый выход из системы», которую мы определили как трудную для реализации в  части V , предоставляется бесплатно. Чтобы быть более точным, в нашей готовой системе автоматически доступен один конкретный подход к пользовательскому интерфейсу единого выхода из системы: если пользователь выходит из любого пользовательского интерфейса (шлюза, пользовательского интерфейса или административного сервера), он выходит из всех другие, предполагая, что в каждом отдельном пользовательском интерфейсе реализована функция выхода из системы (аннулирование сеанса).

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