Эта статья была первоначально написана Дейвом Сайером в блоге Spring.
В этой статье мы продолжим наше обсуждение того, как использовать Spring Security с Angular JS в «одностраничном приложении». Здесь мы покажем, как использовать Angular JS для аутентификации пользователя через форму и извлечения защищенного ресурса для визуализации в пользовательском интерфейсе. Это вторая из серии статей, и вы можете узнать основные строительные блоки приложения или создать его с нуля, прочитав первую статью , или просто перейти к исходному коду в Github., В первой статье мы создали простое приложение, которое использовало базовую аутентификацию HTTP для защиты внутренних ресурсов. В этом мы добавляем форму входа в систему, даем пользователю некоторый контроль над тем, следует ли проходить проверку подлинности или нет, и исправляем проблемы с первой итерацией (в основном отсутствие защиты CSRF).
Напоминание: если вы работаете в этой статье с примером приложения, обязательно очистите кеш браузера от файлов cookie и учетных данных HTTP Basic. В Chrome лучший способ сделать это для отдельного сервера — это открыть новое окно инкогнито.
Добавить навигацию на домашнюю страницу
Ядром одностраничного приложения является статический «index.html». У нас уже был действительно простой, но для этого приложения нам нужно предложить некоторые функции навигации (вход в систему, выход из системы, home), поэтому давайте изменим его (в «src / main / resources / static»):
<!doctype html>
<html>
<head>
<title>Hello AngularJS</title>
<link
href="css/angular-bootstrap.css"
rel="stylesheet">
<style type="text/css">
[ng\:cloak], [ng-cloak], .ng-cloak {
display: none !important;
}
</style>
</head>
<body ng-app="hello" ng-cloak class="ng-cloak">
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
<li class="active"><a href="#/">home</a></li>
<li><a href="#/login">login</a></li>
<li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
</ul>
</div>
<div ng-view class="container"></div>
<script src="js/angular-bootstrap.js" type="text/javascript"></script>
<script src="js/hello.js"></script>
</body>
</html>
На самом деле это не сильно отличается от оригинала. Характерные черты:
- Есть
<ul>
для панели навигации. Все ссылки возвращаются прямо на домашнюю страницу, но таким образом, что Angular распознает, как только мы настроим «маршруты». - Весь контент будет добавлен как «частичные» в
<div>
пометке «ng-view». - «Ng-cloak» перемещен в тело, потому что мы хотим скрыть всю страницу, пока Angular не сможет определить, какие биты рендерить. В противном случае меню и контент могут «мерцать» при перемещении страницы при загрузке страницы.
- Как и в первой статье , внешние ресурсы «angular-bootstrap.css» и «angular-bootstrap.js» генерируются из библиотек JAR во время сборки.
Добавить навигацию в угловое приложение
Давайте изменим приложение «hello» (в «src / main / resources / public / js / hello.js»), чтобы добавить некоторые функции навигации. Мы можем начать с добавления некоторой конфигурации для маршрутов, чтобы ссылки на домашней странице действительно что-то делали. Например
angular.module('hello', [ 'ngRoute' ])
.config(function($routeProvider) {
$routeProvider.when('/', {
templateUrl : 'home.html',
controller : 'home'
}).when('/login', {
templateUrl : 'login.html',
controller : 'navigation'
}).otherwise('/');
})
.controller('home', function($scope, $http) {
$http.get('/resource/').success(function(data) {
$scope.greeting = data;
})
})
.controller('navigation', function() {});
Мы добавили зависимость от модуля Angular под названием «ngRoute», и это позволило нам внедрить магию $routeProvider
в функцию config (Angular выполняет внедрение зависимостей в соответствии с соглашением об именах и распознает имена параметров вашей функции). $routeProvider
Затем используются внутри функция для установки ссылки на «/» ( «домашний» контроллер) и «/ вход» (далее «Логин» контроллер). «TemplateUrls» — это относительные пути от корня маршрутов (т. Е. «/») До «частичных» представлений, которые будут использоваться для визуализации модели, созданной каждым контроллером.
Чтобы использовать модуль «ngRoute», нам нужно добавить строку в конфигурацию «wro.xml», которая создает статические ресурсы (в «src / main / wro»):
<groups xmlns="http://www.isdc.ro/wro">
<group name="angular-bootstrap">
...
<js>webjar:angularjs/1.3.8/angular-route.min.js</js>
</group>
</groups>
Поздравление
Содержимое приветствия со старой домашней страницы можно поместить в «home.html» (прямо рядом с «index.html» в «src / main / resources / static»):
<h1>Greeting</h1>
<div ng-controller="home" ng-show="authenticated">
<p>The ID is {{greeting.id}}</p>
<p>The content is {{greeting.content}}</p>
</div>
<div ng-show="!authenticated">
<p>Login to see your greeting</p>
</div>
Поскольку у пользователя теперь есть выбор: входить в систему или нет (до того, как все это контролировалось браузером), мы должны различать в пользовательском интерфейсе контент, который является безопасным, и контент, который не является. Мы ожидали этого, добавив ссылки на (пока еще не существующую) authenticated
переменную.
Форма входа
Форма входа указывается в «login.html»:
<div class="alert alert-danger" ng-show="error">
There was a problem logging in. Please try again.
</div>
<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>
Это очень стандартная форма входа в систему, с 2 входами для имени пользователя и пароля и кнопкой для отправки формы через ng-submit
. Вам не нужно действие над тегом формы, поэтому, вероятно, лучше вообще не добавлять его. Существует также сообщение об ошибке, которое отображается только в том случае, если значение angular $scope
содержит error
. Элементы управления формой используются ng-model
для передачи данных между HTML и контроллером Angular, и в этом случае мы используем credentials
объект для хранения имени пользователя и пароля. В соответствии с маршрутами, которые мы определили, форма входа в систему связана с контроллером «навигации», который пока пуст, поэтому давайте перейдем к этому, чтобы заполнить некоторые пробелы.
Процесс аутентификации
Для поддержки формы входа, которую мы только что добавили, нам нужно добавить еще несколько функций. На стороне клиента они будут реализованы в контроллере «навигации», а на сервере это будет конфигурация Spring Security.
Отправка формы входа
Чтобы отправить форму, нам нужно определить login()
функцию, на которую мы уже ссылались в форме ng-submit
, и credentials
объект, на который мы ссылались ng-model
. Давайте уточним «навигационный» контроллер в «hello.js» (опуская конфигурацию маршрутов и «домашний» контроллер):
angular.module('hello', [ 'ngRoute' ]) // ... omitted code
.controller('navigation',
function($rootScope, $scope, $http, $location) {
var authenticate = function(callback) {
$http.get('user').success(function(data) {
if (data.name) {
$rootScope.authenticated = true;
} else {
$rootScope.authenticated = false;
}
callback && callback();
}).error(function() {
$rootScope.authenticated = false;
callback && callback();
});
}
authenticate();
$scope.credentials = {};
$scope.login = function() {
$http.post('login', $.param($scope.credentials), {
headers : {
"content-type" : "application/x-www-form-urlencoded"
}
}).success(function(data) {
authenticate(function() {
if ($rootScope.authenticated) {
$location.path("/");
$scope.error = false;
} else {
$location.path("/login");
$scope.error = true;
}
});
}).error(function(data) {
$location.path("/login");
$scope.error = true;
$rootScope.authenticated = false;
})
};
});
Весь код в контроллере «навигация» будет выполняться при загрузке страницы, потому что <div>
содержащая строка меню видима и украшена ng-controller="navigation"
. Помимо инициализации credentials
объекта, он определяет 2 функции, login()
которые нам нужны в форме, и локальную вспомогательную функцию, authenticate()
которая пытается загрузить «пользовательский» ресурс из серверной части. authenticate()
Функция вызывается , когда контроллер загружается , чтобы увидеть , если пользователь на самом деле уже проверку подлинности (например , если он освежил браузер в середине сеанса). Нам нужна authenticate()
функция для выполнения удаленного вызова, потому что настоящая аутентификация выполняется сервером, и мы не хотим доверять браузеру, чтобы отслеживать ее.
authenticate()
Функция устанавливает флаг приложения широко называемый , authenticated
который мы уже использовали в нашем «home.html» для контроля , какие части страниц оказаны. Мы делаем это, используя, $rootScope
потому что за ним удобно и легко следовать, и нам нужно разделить authenticated
флаг между «навигационными» и «домашними» контроллерами. Специалисты Angular могут предпочесть обмениваться данными через общий определенный пользователем сервис (но в конечном итоге это тот же механизм).
Он login()
создает POST для относительного ресурса (относительно корня развертывания вашего приложения) «/ login» с учетными данными в теле, закодированными в форме (Angular делает все в JSON по умолчанию, поэтому мы должны были это явно указать). Вместо того, чтобы полагаться на возможность получения состояния аутентификации из результата POST, login()
функция использует authenticate()
помощника. Использовать результат POST сложно, если мы не знаем наверняка, что сервер будет делать в случае успеха или сбоя, поэтому стоит потратить дополнительный сетевой трафик на проверку подлинности в общем случае (например, поведение Spring Security по умолчанию). является отправка 302 при успехе и неудаче, и Angular будет следовать перенаправлению, так что мы должны были бы фактически проанализировать ответ от этого). login()
Функция также устанавливает локальную $scope.error
помечайте соответствующим образом, когда мы получим результат аутентификации, который используется для управления отображением сообщения об ошибке над формой входа.
Текущий Аутентифицированный Пользователь
Для обслуживания authenticate()
функции нам нужно добавить новую конечную точку в бэкэнд:
@SpringBootApplication
@RestController
public class UiApplication {
@RequestMapping("/user")
public Principal user(Principal user) {
return user;
}
...
}
Это полезный трюк в приложении Spring Security. Если ресурс «/ user» доступен, он вернет аутентифицированного пользователя (an Authentication
), а в противном случае Spring Security перехватит запрос и отправит его через AuthenticationEntryPoint
.
Альтернативная реализация этого authenticate()
(на стороне клиента) и «/ user» (на стороне сервера) может просто проверить HTTP-ответ от простого GET любого защищенного ресурса (например, средство 401 authenticated=false
). Это немного хрупко, потому что это зависит от нестандартной конфигурации сервера с использованием пользовательских AuthenticationEntryPoint
.
Обработка запроса на вход в систему на сервере
Spring Security упрощает обработку запроса на вход. Нам просто нужно добавить некоторую конфигурацию в наш основной класс приложения (например, как внутренний класс):
@SpringBootApplication
@RestController
public class UiApplication {
...
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().and()
.authorizeRequests()
.antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll()
.anyRequest().authenticated();
}
}
}
Это стандартное приложение Spring Boot с настройкой Spring Security, просто добавляющее форму входа и позволяющее анонимный доступ к статическим (HTML) ресурсам (ресурсы CSS и JS уже доступны по умолчанию). Ресурсы HTML должны быть доступны анонимным пользователям, а не просто игнорироваться Spring Security по понятным причинам.
CSRF Защита
Приложение почти готово к использованию, но если вы попытаетесь запустить его, вы обнаружите, что форма входа не работает. Посмотрите ответы в браузере, и вы поймете, почему:
POST /login HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
username=user&password=password
HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...
{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}
Это хорошо, потому что это означает, что встроенная защита CSRF Spring Security активирована, чтобы мы не могли выстрелить себе в ногу. Все, что ему нужно, — это отправленный ему токен в заголовке под названием «X-CSRF». Значение токена CSRF было доступно на стороне сервера в HttpRequest
атрибутах первоначального запроса, который загружал домашнюю страницу. Чтобы передать его клиенту, мы могли бы отобразить его, используя динамическую HTML-страницу на сервере, или предоставить его через пользовательскую конечную точку, или же мы могли бы отправить его в виде файла cookie. Последний вариант является лучшим, потому что Angular имеет встроенную поддержку CSRF (которую он называет «XSRF») на основе файлов cookie.
Таким образом, все, что нам нужно на сервере — это специальный фильтр, который будет отправлять куки. Angular хочет, чтобы имя файла cookie было «XSRF-TOKEN», а Spring Security предоставляет его в качестве атрибута запроса, поэтому нам просто нужно перенести значение из атрибута запроса в файл cookie:
public class CsrfHeaderFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
.getName());
if (csrf != null) {
Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
String token = csrf.getToken();
if (cookie==null || token!=null && !token.equals(cookie.getValue())) {
cookie = new Cookie("XSRF-TOKEN", token);
cookie.setPath("/");
response.addCookie(cookie);
}
}
filterChain.doFilter(request, response);
}
}
Чтобы завершить работу и сделать ее полностью универсальной, мы должны быть осторожны, чтобы указать путь к cookie для контекстного пути приложения (вместо жестко заданного в «/»), но этого достаточно для приложения, над которым мы работаем.
Нам нужно где-то установить этот фильтр в приложении, и он должен идти после Spring Security, CsrfFilter
чтобы был доступен атрибут запроса. Поскольку у нас есть Spring Security для защиты этих ресурсов, нет лучшего места, чем в цепочке фильтров Spring Security, например, расширение SecurityConfiguration
вышеперечисленного:
@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().and()
.authorizeRequests()
.antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll().anyRequest()
.authenticated().and()
.addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
}
}
Еще одна вещь, которую мы должны сделать на сервере, — сказать Spring Security ожидать токен CSRF в формате, который Angular хочет отправить обратно (заголовок под названием «X-XRSF-TOKEN» вместо стандартного «X-CSRF-TOKEN»). «). Мы делаем это, настраивая фильтр CSRF:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().and()
...
.csrf().csrfTokenRepository(csrfTokenRepository());
}
private CsrfTokenRepository csrfTokenRepository() {
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
repository.setHeaderName("X-XSRF-TOKEN");
return repository;
}
После внесения этих изменений нам не нужно ничего делать на стороне клиента, и теперь форма входа в систему работает.
Выйти
Приложение практически закончено функционально. Последнее, что нам нужно сделать, — это реализовать функцию выхода из системы, которую мы нарисовали на домашней странице. Вот напоминание о том, как выглядит панель навигации:
<div ng-controller="navigation" class="container">
<ul class="nav nav-pills" role="tablist">
<li class="active"><a href="#/">home</a></li>
<li><a href="#/login">login</a></li>
<li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
</ul>
</div>
Если пользователь аутентифицирован, то мы показываем ссылку «logout» и подключаем ее к logout()
функции в контроллере «navigation». Реализация функции относительно проста:
angular.module('hello', [ 'ngRoute' ]).
// ...
.controller('navigation', function(...) {
...
$scope.logout = function() {
$http.post('logout', {}).success(function() {
$rootScope.authenticated = false;
$location.path("/");
}).error(function(data) {
$rootScope.authenticated = false;
});
}
...
});
Он отправляет HTTP-запрос POST в «/ logout», который нам теперь нужно реализовать на сервере. Это просто:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().and()
.logout()
...
;
}
(мы только что добавили .logout()
в HttpSecurity
конструктор конфигурации).
Как это работает?
Взаимодействие между браузером и бэкэндом можно увидеть в вашем браузере, если вы используете некоторые инструменты разработчика (обычно F12 открывает это, работает в Chrome по умолчанию, требует плагина в Firefox). Вот резюме:
ГЛАГОЛА | ПУТЬ | ПОЛОЖЕНИЕ ДЕЛ | ОТВЕТ |
---|---|---|---|
ПОЛУЧИТЬ | / | 200 | index.html |
ПОЛУЧИТЬ | /css/angular-bootstrap.css | 200 | Twitter загружает CSS |
ПОЛУЧИТЬ | /js/angular-bootstrap.js | 200 | Бутстрап и угловой JS |
ПОЛУЧИТЬ | /js/hello.js | 200 | Логика применения |
ПОЛУЧИТЬ | / пользователь | 302 | Перенаправить на страницу входа |
ПОЛУЧИТЬ | /авторизоваться | 200 | Страница входа Whitelabel (игнорируется) |
ПОЛУЧИТЬ | /ресурс | 302 | Перенаправить на страницу входа |
ПОЛУЧИТЬ | /авторизоваться | 200 | Страница входа Whitelabel (игнорируется) |
ПОЛУЧИТЬ | /login.html | 200 | Угловая форма входа в систему частичная |
ПОСЛЕ | /авторизоваться | 302 | Перенаправление на домашнюю страницу (игнорируется) |
ПОЛУЧИТЬ | / пользователь | 200 | Аутентифицированный пользователь JSON |
ПОЛУЧИТЬ | /ресурс | 200 | JSON приветствие |
The responses that are marked “ignored” above are HTML responses received by Angular in an XHR call, and since we aren’t processing that data the HTML is dropped on the floor. We do look for an authenticated user in the case of the “/user” resource, but since it isn’t there in the first call, that response is dropped.
Look more closely at the requests and you will see that they all have cookies. If you start with a clean browser (e.g. incognito in Chrome), the very first request has no cookies going off to the server, but the server sends back “Set-Cookie” for “JSESSIONID” (the regularHttpSession
) and “X-XSRF-TOKEN” (the CRSF cookie that we set up above). Subsequent requests all have those cookies, and they are important: the application doesn’t work without them, and they are providing some really basic security features (authentication and CSRF protection). The values of the cookies change when the user authenticates (after the POST) and this is another important security feature (preventing session fixation attacks).
Note: it is not adequate for CSRF protection to rely on a cookie being sent back to the server because the browser will automatically send it even if you are not in a page loaded from your application (a Cross Site Scripting attack, otherwise known as XSS). The header is not automatically sent, so the origin is under control. You might see that in our application the CSRF token is sent to the client as a cookie, so we will see it being sent back automatically by the browser, but it is the header that provides the protection.
Help, How is My Application Going to Scale?
“But wait…” you are saying, “isn’t it Really Bad to use session state in a single-page application?” The answer to that question is going to have to be “mostly”, because it very definitely is a Good Thing to use the session for authentication and CSRF protection. That state has to be stored somewhere, and if you take it out of the session, you are going to have to put it somewhere else and manage it manually yourself, on both the server and the client. That’s just more code and probably more maintenance, and generally re-inventing a perfectly good wheel.
“But, but…” you are going to respond, “how do I scale my application horizontally now?” This is the “real” question you were asking above, but it tends to get shortened to “session state is bad, I must be stateless”. Don’t panic. The main point to take on board here is that security isstateful. You can’t have a secure, stateless application. So where are you going to store the state? That’s all there is to it. Rob Winch gave a very useful and insightful talk at Spring Exchange 2014 explaining the need for state (and the ubiquity of it — TCP and SSL are stateful, so your system is stateful whether you knew it or not), which is probably worth a look if you want to look into this topic in more depth.
The good news is you have a choice. The easiest choice is to store the session data in-memory, and rely on sticky sessions in your load balancer to route requests from the same session back to the same JVM (they all support that somehow). That’s good enough to get you off the ground and will work for a really large number of use cases. The other choice is to share the session data between instances of your application. As long as you are strict and only store the security data, it is small and changes infrequently (only when users log in and out, or their session times out), so there shouldn’t be any major infrastructure problems. It’s also really easy to do with Spring Session. We’ll be using Spring Session in the next article in this series, so there’s no need to go into any detail about how to set it up here, but it is literally a few lines of code and a Redis server, which is super fast.
Tip: another easy way to set up shared session state is to deploy your application as a WAR file to Cloud Foundry Pivotal Web Services and bind it to a Redis service.
But, What about My Custom Token Implementation (it’s Stateless, Look)?
If that was your response to the last section, then read it again because maybe you didn’t get it the first time. It’s probably not stateless if you stored the token somewhere, but even if you didn’t (e.g. you use JWT encoded tokens), how are you going to provide CSRF protection? It’s important. Here’s a rule of thumb (attributed to Rob Winch): if your application or API is going to be accessed by a browser, you need CSRF protection. It’s not that you can’t do it without sessions, it’s just that you’d have to write all that code yourself, and what would be the point because it’s already implemented and works perfectly well on top of HttpSession
(which intuen is part of the container you are using and baked into specs since the very beginning)? Even if you decide you don’t need CSRF, and have a perfectly “stateless” (non-session based) token implementation, you still had to write extra code in the client to consume and use it, where you could have just delegated to the browser and server’s own built-in features: the browser always sends cookies, and the server always has a session (unless you switch it off). That code is not business logic, and it isn’t making you any money, it’s just an overhead, so even worse, it costs you money.
Conclusion
The application we have now is close to what a user might expect in a “real” application in a live environment, and it probably could be used as a template for building out into a more feature rich application with that architecture (single server with static content and JSON resources). We are using the HttpSession
for storing security data, relying on our clients to respect and use the cookies we send them, and we are comfortable with that because it lets us concentrate on our own business domain. In the next article we expand the architecture to a separate authentication and UI server, plus a standalone resource server for the JSON. This is obviously easily generalised to multiple resource servers. We are also going to introduce Spring Session into the stack and show how that can be used to share authentication data.