17.07.2016

Spring Security 4 и AngularJS. Аутентификация в AJAX веб-приложении.

Spring Framework можно назвать стандартом де-факто в мире Java. Еще бы, ведь некоторые концепции, реализованные в этом фреймворке повлияли на саму спецификацию JavaEE. Spring Security является дочерним проектом Spring, и предоставляет средства аутентификации и авторизации для Java/JavaEE приложений. С другой стороны, в мире фронтенда победил JavaScript и фреймворки, позволяющие создавать SPA веб-приложения - AngularJS, ReactJS, BackboneJS и другие. Я хочу привести пример, как можно успешно использовать Spring Security 4 совместно с Angular. В этом примере мы напишем REST бекенд, используя Spring MVC. Также мы реализуем аутентификацию и авторизацию с помощью Spring Security, в то время как на фронтенде будет использоваться AngularJS. 

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

В примере будет использоваться Java 8, Tomcat 8, Spring 4.3, Spring Security 4.1, AngularJS 1.4.9. В конце поста вы можете найти ссылку на полный код проекта. 

REST backend. 

Начнем с бекенда. Проект собирается с помощью Maven и имеет такую структуру:

В директории webapp/resources будет расположено одностраничное приложение Angular. К его рассмотрению перейдем чуть позже. Структура пакетов Java выглядит следующим образом:

Аутентификация через AJAX запрос.

В пакете org.develnotes.web находятся два контроллера Spring MVC. HomeController - контроллер, который при переходе на веб-контекст приложения перенаправляет пользователя на страницу index.html, где и расположено наше Angular приложение. HomeController.java:

//... объявление пакета ...
//... импорты ...
@Controller
public class HomeController {

	@RequestMapping(value = "/", method = RequestMethod.GET)
	public String home() {
		return "redirect:/resources/index.html";
	}
}

LoginController - контроллер отвечающий за аутентификацию. LoginController.java:

//... объявление пакета ...
//... импорты ...
@RestController
@RequestMapping("/auth")
public class LoginController {

	private Logger log = LoggerFactory.getLogger(LoginController.class);

	@RequestMapping(value = "/user", method = RequestMethod.GET, 
	produces = "application/json")
	public User getUser(Principal principial) {
		
	if (principial != null) {
			
	  log.info("Got user info for login = " + principial.getName());
			
	    if (principial instanceof AbstractAuthenticationToken){
				return (User) ((AbstractAuthenticationToken) 
				principial).getPrincipal();
	    } 
	  }
		
	return null;
		
	}
	
	@RequestMapping(value = "/logout", method = RequestMethod.POST)
	public void logout(HttpServletRequest rq, HttpServletResponse rs) {
		
	    SecurityContextLogoutHandler securityContextLogoutHandler = 
		new SecurityContextLogoutHandler();
	    securityContextLogoutHandler.logout(rq, rs, null);
		
	}
	
}

Здесь расположены метод получения данных пользователя getUser и метод logout, с помощью которого осуществляется выход из системы. Метод getUser имеет два назначения - с одной стороны это получение данных пользователя по AJAX запросу, а с другой - аутентификация пользователя. Посмотрим как это работает. Поскольку задачей является создание одностраничного приложения, где роутинг между различными представлениями (разделами веб-приложения) будет реализован на фронтенде, мы не можем использовать аутентификацию основанную на формах, как это обычно делается при использовании Spring Security. Вместо этого, необходимо использовать HTTP basic аутентификацию. Что это значит? Это значит, что при каждом HTTP запросе к защищенному ресурсу, сервер будет ожидать получить в заголовке запроса данные для аутентификации. Для включения HTTP basic аутентификации, добавим в секцию http в spring-security.xml:

<http-basic entry-point-ref="http403ForbiddenEntryPoint" />

Обратите внимание на свойство entry-point-ref. Мы указали бин http403ForbiddenEntryPoint. Этот бин (он создается в SpringSecurityBeans.java) отвечает за то, чтобы при обращении клиента к защищенному ресурсу, в случае отсутствия аутентификации, сервер возвращал HTTP статус 403 Forbidden. По умолчанию, сервер будет возвращать в ответе статус 401 Unauthorized. Проблема в том, что большинство браузеров, получив в такой ответ от сервера (в том числе при AJAX запросе), показывают пользователю диалог ввода логина и пароля. Например, в Google Chrome это выглядит так:

Подобное диалоговое окно контролируется браузером и убрать его невозможно. Нам это окно совершенно не нужно, у нас будет собственный диалог ввода логина и пароля, поэтому ответ 403 в данном случае отлично подходит т.к. для него браузер не выдает таких диалоговых окон. Итак, когда клиент делает GET запрос по адресу user происходит следующее:

1. Если в запросе нет заголовка аутентификации, или переданы неверные логин и пароль, метод не возвращает данных пользователя и клиент получает HTTP ответ со статусом 403.

2. Если в запросе передан корректный заголовок аутентификации, Spring Secruty аутентифицирует пользователя и подставляет в аргумет principal метода getUser(Principal principal ) данные пользователя. Клиент получает HTTP ответ со статусом 200, в котором содержаться данные пользователя в JSON. 

Таким образом, для того чтобы войти в систему нужно сделать AJAX запрос по адресу user, предоставив данные для входа (логин и пароль). Это работает благодаря включению в Spring Security точки входа HTTP basic, и конфигурированию authentication-manager в spring-security.xml:

<authentication-manager alias="authenticationManager">
	<authentication-provider user-service-ref="userDetailsServiceStub">
			<password-encoder hash="bcrypt" />
	</authentication-provider>
</authentication-manager>

Здесь мы указываем, что пароль хранится не в открытом виде, а ввиде хэша bcrypt. Также здесь указана ссылка на сервис, который предоставляет данные пользователя - userDetailsServiceStub. Этот бин типа UserDetailsServiceStub, который реализует интерфейс org.springframework.security.core.userdetails.UserDetailsService:

//... объявление пакета ...
//... импорты ...
@Service
public class UserDetailsServiceStub implements UserDetailsService {

	@Override
	public UserDetails loadUserByUsername(String username) 
                              throws UsernameNotFoundException {

	if ("user".equals(username)) {

	  List auths = new ArrayList();
	  auths.add(new SimpleGrantedAuthority("ROLE_USER"));
			
	  return new User(username, 
	  "$2a$06$6rdrf2NymngVRlA8nkA9/OA5a2XfP2975VCo1Nb7UdiPX6xFqnyZ2", 
	  auths, "Россия",
	  "Василий Васильевич");
	}
	throw new UsernameNotFoundException("User with name = " + username + " not found");

	}
}

Класс UserDetailServiceStub реализует метод loadUserByUsername, который загружает пользователя по его логину. В данном случае это сервис "заглушка", который может вернуть только одного пользователя с логином user. Если логин передан верно, то мы создаем пользователя, и заполняем нужные данные - логин, хэш пароля, роль пользователя в системе и некоторые дополнительные данные, которые нужны для примера. В реальном же проекте данные о пользователях скорее всего будут получаться из БД. В нашем примере не будет сложной авторизации, а будет только одна роль - ROLE_USER. Пользователь с этой ролью сможет смотреть закрытые разделы. Для представления данных пользователя, мы будем использовать собственную реализацию интерфейса UserDetails. User.java:

//... объявление пакета ...
//... импорты ...
@JsonIgnoreProperties({"authorities"})
public class User implements UserDetails {

	private static final long serialVersionUID = 6980833944761230840L;
	private boolean enabled;
	private boolean credentialsNonExpired;
	private boolean accountNonLocked;
	private boolean accountNonExpired;
	private String username;
	private String password;
	private List authorities;
	private String country;
	private String fullName;
	
//... конструкторы ...
//... геттеры и сеттеры ...	
}

Необходимость собственной реализации (а не использование стандартного класса из пакетов Spring) обсусловлена двумя причинами: для примера мы добавим дополнительные поля - страна и полное имя;  мы будем использовать этот класс для сериализации в JSON и ограничим сериализацию списка ролей пользователя с помощью аннотации @JsonIgnoreProperties({"authorities"}). 

Кроме метода user, в LoginController.java есть метод logout. Когда клиент отправляет AJAX POST запрос, сессия очищается и пользователь выходит из системы. Необходимость создания своего метода выхода обсусловлена тем, что стандартный logout (если его включить в spring-security.xml) в ответ направляет редирект на заданную страницу, а нам, в связи со спецификой работы Angular, такое поведение не подходит. Мы реализовали аутентификацию и выход из системы с помощью AJAX запроса. Теперь необходимо настроить защиту от CSRF атак, и сделать возможность сохранения логина и пароля в cookies браузера (галочка "запомнить").

Настройка CSRF токена.

В Spring Security 4 по умолчанию включена защита от атак типа CSRF. Суть идеи заключается в том, что вместе с запросом клиент отправляет заголовок, в котором содержится CSRF токен (неплохая статья на эту тему).

Для того чтобы все это работало с Angular, необходимо произвести настройку. В классе SpringSecurityBeans.java создадим бин customCsrfTokenRepository:

@Bean
public CsrfTokenRepository customCsrfTokenRepository() {

	HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
	repository.setHeaderName("X-XSRF-TOKEN");

	return repository;
}

Также создадим бин customCsrfHeaderFilter. CustomCsrfHeaderFilter.java:

//... объявление пакета ...
//... импорты ...
@Component
public class CustomCsrfHeaderFilter 
             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);
	}
}

После чего эти бины нужно включить в spring-security.xml в секцию http:

<csrf token-repository-ref="customCsrfTokenRepository" />
<custom-filter after="CSRF_FILTER" ref="customCsrfHeaderFilter" />

Созданный нами фильтр включается после фильтра CSRF_FILTER. Создание собственного фильтра и бина customCsrfTokenRepository необходимо, потому что Angular по умолчанию ищет cookie с именем XSRF-TOKEN и добавляет в запрос заголовок с именем X-XSRF-TOKEN. Таким образом, мы обеспечили корректную связь механизмов защиты от CSRF атак Spring Security и Angular. 

Сохранение логина и пароля.

Для того, чтобы пользователю не нужно было каждый раз вводить логин и пароль, достаточно часто используется функция "запомнить", которая может быть реализована несколькими способами. В этом примере, мы будем использовать сохранение данных в cookies браузера. Cookies будут формироваться на стороне клиента, если пользователь выберет опцию "запомнить" при входе в систему, и будут иметь срок действия один месяц. О том, как они будут формироваться - посмотрим при написании клиентской части. А пока посмотрим что нужно сделать на бекенде. Первым делом нужно создать бин rememberMeServices в классе SpringSecurityBeans:

@Bean
public TokenBasedRememberMeServices rememberMeServices() {

	TokenBasedRememberMeServices service = 
	new TokenBasedRememberMeServices(
	"DEVELNOTES_REMEMBER_TOKEN", userDetailsService);

	service.setCookieName("DEVELNOTES_REMEMBER_ME_COOKIE");
	service.setUseSecureCookie(false);
	service.setAlwaysRemember(false);

	return service;
}

Затем укажем ссылку на этот бин в spring-security.xml в разделе http:

<remember-me key="DEVELNOTES_REMEMBER_TOKEN" services-ref="rememberMeServices" />

Cookie с именем DEVELNOTES_REMEMBER_ME_COOKIE будут создаваться на фронтенде, при входе пользователя в систему, в том случае, если он выбрал опцию "Запомнить". Когда такие cookie будут переданны с запросом, Spring Security будет осуществлять аутентификацию именно по ним. Таким образом, пользователю не нужно будет вводить логин и пароль.  

Теперь необходимо указать Spring Security страницы, которые нужно закрыть. Для этого добавим тег intercept-url с указанием паттерна /resources/partials/protected/*. В директории protected будут лежать закрытые страницы, которые будут запрашиваться AngularJS с помощью AJAX. После выполненения всех настроек, spring-security.xml будет выглядеть так:

<beans:beans xmlns="http://www.springframework.org/schema/security"
	xmlns:beans="http://www.springframework.org/schema/beans" 
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://www.springframework.org/schema/beans 
	http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
    http://www.springframework.org/schema/security 
    http://www.springframework.org/schema/security/spring-security-4.1.xsd">

	<http>
		<intercept-url pattern="/resources/partials/protected/*"
			access="hasRole('USER')" />
		<http-basic entry-point-ref="http403ForbiddenEntryPoint" />
		<csrf token-repository-ref="customCsrfTokenRepository" />
		<custom-filter after="CSRF_FILTER" ref="customCsrfHeaderFilter" />
		<remember-me key="DEVELNOTES_REMEMBER_TOKEN" services-ref="rememberMeServices" />
	</http>

	<authentication-manager alias="authenticationManager">
		<authentication-provider user-service-ref="userDetailsServiceStub">
			<password-encoder hash="bcrypt" />
		</authentication-provider>
	</authentication-manager>

</beans:beans>

С бекендом разобрались, теперь необходимо поработать над форнтендом. 

Создаем приложение AngularJS. 

Веб-приложение Angular будет расположено на странице index.html, на которую будет осуществляться переход (см. HomeController.java):

В директории webapp/resources/js лежат скрипты, необходимые для Angular, само наше приложение - app.js, и md5.min.js - реализация алгоритма MD5, которая понадобится нам для создания Cookie DEVELNOTES_REMEMBER_ME_COOKIE. На странице index.html подключим необходимые скрипты и добавим ng-view:

<!DOCTYPE HTML>
<html ng-app="mainModule">

<head>
<title>Spring Security/AngularJS demo</title>

<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/style.css">
<script>
	function getContextPath() {
		return window.location.pathname.substring(0, 
		window.location.pathname.indexOf("/", 2));
	}
</script>
</head>
<body>
	<section id="main-body" ng-view></section>
	<script src="js/angular.min.js"></script>
	<script src="js/angular-cookies.min.js"></script>
	<script src="js/angular-route.min.js"></script>
	<script src="js/md5.min.js"></script>
	<script src="js/app.js"></script>
</body>
</html>

Также обратите внимание на функцию getContextPath(), она должна строку адреса вместе с веб-контекстом приложения. Если вы будете запускать пример на Tomcat 8 через Eclipse, то веб-контекстом будет являться имя проекта, т.е. SpringAngular. Таким образом, функция getContextPath() вернет адрес http://localhost:8080/SpringAngular

В файле app.js создадим три контроллера. HomeController - контроллер общедоступного раздела, SanctumController - контроллер закрытого раздела, LoginController - контроллер представления для ввода логина и пароля:

function HomeController($scope, $window, authService) {

	$scope.isLoggedIn = false;

	authService.getUserInfo(function(userInfo) {
		$scope.isLoggedIn = userInfo ? true : false;
	});

	$scope.logout = function() {
		authService.logout().success(function() {
			$window.location.reload();
		});
	};
}
function SanctumController($window, $scope, authService) {

	$scope.fullName = "";
	$scope.country = "";
	$scope.imagePath = getContextPath()
			+ "/resources/partials/protected/photo.jpg";

	authService.getUserInfo(function(userInfo) {
		if (userInfo) {
			$scope.fullName = userInfo.fullName;
			$scope.country = userInfo.country;
		}
	});

	$scope.logout = function() {
		authService.logout().success(function() {
			$window.location.href = "index.html";
		});
	};
}
function LoginController($rootScope, $window, $scope, authService) {

	$scope.username = '';
	$scope.password = '';
	$scope.remember = false;
	$scope.loginError = false;

	$scope.login = function() {
		authService
				.authenticate(
						$scope.username,
						$scope.password,
						$scope.remember,
						function(success) {
							if (success) {
								$window.location = $rootScope.targetUrl ? $rootScope.targetUrl
										: "#/home";
							} else
								$scope.loginError = true;
						});
	};

	$scope.resetStatus = function() {
		$scope.loginError = false;
	};

}

Как видно, все контроллеры будут использовать сервис authService. Этот сервис реализует следующие функции:

authenticate(name, password, remember, callback) - функция аутентификации пользователя по логину и паролю. Если будет передан аргумент remember = true, то будут созданы cookie для последующей аутентификации пользователя без ввода логина и пароля ("запомнить"). Cookie должны создаваться в формате, описанном в документации Spring Security. В случае успешного входа пользователя в систему, будет вызвана функция callback с аргументом true.

getUserInfo(callback) - функция получения данных текущего пользователя (вся доступная информация для аккаунта). Должна вызваться только после входа пользователя в систему. В случае успеха вызовет функцию callback с данными пользователя. 

logout() - выйти из системы. 

createRememberMeCookie(userdetails) - создать cookie для последующей аутентификации пользователя без ввода логина и пароля. Эта функция использует md5.min.js для создания cookie в формате, описанном в документации Spring Security.

removeRememberMeCookie()удаляет cookie, которые были созданы функцией createRememberMeCookie. 

После контроллеров создаем модуль mainModule, добавляем в него сервис authService и контроллеры:

var mainModule = angular
		.module('mainModule', [ 'ngRoute', 'ngCookies' ])
		.service(
				'authService',
				[
						'$http',
						'$cookies',
						function($http, $cookies) {

							this.authenticate = function(name, password,
									remember, callback) {

								if (!name || !password) {
									callback(false);
									return;
								}

								var headers = {
									authorization : "Basic "
											+ btoa(name + ":" + password)
								};

								$http.get(getContextPath() + '/auth/user', {
									'headers' : headers
								}).success(function(data) {

									if (data) {
										if (remember) {
											createRememberMeCookie(data);
										}
										callback(true);
									} else {
										callback(false);
									}

								}).error(function() {
									callback(false);
								});
							};

							this.getUserInfo = function(callback) {

								$http.get(getContextPath() + '/auth/user', {})
										.success(function(data) {

											if (data.username) {
												callback(data);
											} else {
												callback(null);
											}

										}).error(function() {
											callback(null);
										});
							};

							this.logout = function() {
								removeRememberMeCookie();
								return $http.post(getContextPath()
										+ '/auth/logout', {});
							};

							var createRememberMeCookie = function(userdetials) {

								var name = userdetials.username;
								var pwd = userdetials.password;
								var expireDate = new Date();
								expireDate.setDate(expireDate.getDate() + 30);

								var cookieValue = btoa(name
										+ ":"
										+ expireDate.getTime().toString()
										+ ":"
										+ md5(name
												+ ":"
												+ expireDate.getTime()
														.toString() + ":" + pwd
												+ ":"
												+ "DEVELNOTES_REMEMBER_TOKEN"));

								$cookies.put('DEVELNOTES_REMEMBER_ME_COOKIE',
										cookieValue, {
											'expires' : expireDate,
											'path' : '/'
										});
							};

							var removeRememberMeCookie = function() {
								var expireDate = new Date();
								expireDate.setDate(expireDate.getDate() - 1);
								$cookies.put('DEVELNOTES_REMEMBER_ME_COOKIE',
										'', {
											'expires' : expireDate,
											'path' : '/'
										});
							};

						} ])

		.controller('HomeController',
				[ '$scope', '$window', 'authService', HomeController ])
		.controller('SanctumController',
				[ '$window', '$scope', 'authService', SanctumController ])
		.controller(
				'LoginController',
				[ '$rootScope', '$window', '$scope', 'authService',
						LoginController ])

		.config(
				[
						'$routeProvider',
						'$httpProvider',
						function($routeProvider, $httpProvider) {

							$httpProvider.interceptors.push('responseObserver');

							$routeProvider
									.when('/home', {

										templateUrl : 'partials/home.html',
										controller : 'HomeController'

									})
									.when('/login', {

										templateUrl : 'partials/login.html',
										controller : 'LoginController'

									})
									.when(
											'/photos',
											{

												templateUrl : 'partials/protected/photos.html',
												controller : 'SanctumController'

											})
									.when(
											'/profile',
											{

												templateUrl : 'partials/protected/profile.html',
												controller : 'SanctumController'

											}).otherwise({
										redirectTo : '/home'
									});

						} ])

		.factory(
				'responseObserver',
				[
						'$rootScope',
						'$q',
						'$window',
						function responseObserver($rootScope, $q, $window) {

							return {
								'responseError' : function(errorResponse) {
									switch (errorResponse.status) {
									case 403:

										if ($window.location.hash
												.indexOf("login") == -1)
											$rootScope.targetUrl = $window.location.hash;

										$window.location = '#/login';
										break;
									}

									return $q.reject(errorResponse);
								}
							};
						} ]);

Кроме создания описанного выше сервиса authService, здесь настраивается роутинг с помощью $routeProvider, а также добавляется интерсептор для $httpProvider - responseObserver. Этот интерсептор нужен для перенаправления пользователя на раздел ввода логина и пароля, в том случае, если пользователь не вошел в систему, но осуществил переход на закрытую страницу. 

Все готово, запустим приложение на Tomcat. При переходе на http://localhost:8080/SpringAngular, произойдет перенаправление на страницу index.html, на которой мы увидим список разделов:

На странице есть две ссылки на закрытые разделы - Профиль и Фотографии. Попробуем нажать на Профиль:

Не смотря на то, что ссылка указывала на раздел профиля, произошло перенаправление на #login. Это работает благодаря интерсептору, который был добавлен в приложение AngularJS. После ввода правильного логина и пароля (имя user и хэш пароля qwerty сохранены в UserDetailsServiceStub.java), мы попадаем в профиль пользователя:

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

Если при входе в систему выбрать опцию "Запомнить", то при последующем переходе на закрытые разделы, ввод логина и пароля не потребуется. Например, откроем браузер заново, и сразу перйдем на раздел Фотографии (выбрав предварительно опцию "Запомнить"):

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

 

Все работает как нужно! Ресурсы, которые находятся в папке protected действительно закрыты от несанкционированного доступа. 

Скачать код примера

GIT



Теги: javaEE web programming angular Spring

comments powered by Disqus