Эта вторая часть серии Stateless Spring Security посвящена исследованию средств аутентификации без сохранения состояния. Если вы пропустили первую часть о CSRF, вы можете найти ее здесь .
Поэтому, когда речь идет об аутентификации, все дело в том, чтобы клиент идентифицировал себя с сервером проверяемым образом. Обычно это начинается с того, что сервер предоставляет клиенту вызов, например, запрос на ввод имени пользователя / пароля. Сегодня я хочу сосредоточиться на том, что происходит после прохождения такой первоначальной (ручной) проверки, и на том, как бороться с автоматической повторной аутентификацией последующих HTTP-запросов.
Общие подходы
Сеанс Cookie
Наиболее распространенный подход, который мы, вероятно, все знаем, — это использовать сгенерированный сервером секретный токен (ключ сеанса) в форме файла cookie JSESSIONID. Первоначальная настройка для этого почти ничего не значит в наши дни, возможно, заставляя вас забыть, что у вас есть выбор, чтобы сделать здесь в первую очередь. Даже без дальнейшего использования этого «ключа сеанса» для сохранения любого другого состояния «в сеансе» сам ключ также фактически является состоянием . Т.е. без общего и постоянного хранения этих ключей никакая успешная аутентификация не переживет перезагрузку сервера или балансировку нагрузки запросов к другому серверу.
Ключи OAuth2 / API
Всякий раз, когда речь идет о REST API и безопасности; OAuth2 и другие типы API-ключей упоминаются. В основном они включают отправку пользовательских токенов / ключей в заголовке HTTP-авторизации. При правильном использовании оба избавляют клиентов от работы с файлами cookie, используя вместо этого заголовок. Это решает уязвимости CSRF и другие проблемы, связанные с Cookie. Однако одна вещь, которую они не решают, — это необходимость для сервера проверять представленные ключи аутентификации, в значительной степени требуя некоторого постоянного и поддерживаемого общего хранилища для связи ключей с пользователями / авторизациями.
Подходы без гражданства
1. HTTP Basis Auth
Самый старый и самый грубый способ борьбы с аутентификацией. Просто попросите пользователя отправить свое имя пользователя / пароль при каждом запросе. Это, вероятно, звучит ужасно, но, учитывая любой из упомянутых выше подходов, также посылает секретные ключи по проводам, на самом деле это вовсе не так уж и безопасно. Именно пользовательский опыт и гибкость делают другие подходы лучшим выбором.
2. Сервер подписал токены
Небольшая хитрость в обработке состояния между запросами без сохранения состояния состоит в том, чтобы сервер «подписал» его. Затем он может транспортироваться туда и обратно между клиентом / сервером каждый запрос с гарантией того, что он не будет изменен. Таким образом, любые идентификационные данные пользователя могут быть переданы в виде простого текста, добавив к нему специальный хэш подписи. Считая, что он подписан, сервер может просто проверить, соответствует ли хэш подписи полученному контенту, без необходимости сохранять какое-либо состояние на стороне сервера.
Общий стандарт, который может использоваться для этого, — JSON Web Tokens (JWT), который все еще находится в стадии разработки. Для этого поста в блоге я хотел бы получить пачкаться, хотя и пропустить полное соответствие и кричать об использовании библиотеки, которая идет с ним. Выбирая именно то, что нам на самом деле нужно от этого. (Оставляя в заголовке / переменных хеш-алгоритмы и url-безопасное кодирование base64)
Реализация
Как уже упоминалось, мы собираемся развернуть нашу собственную реализацию, используя Spring Security и Spring Boot, чтобы объединить все это вместе. Без какой-либо библиотеки или причудливого API, запутывающего то, что действительно происходит на уровне токенов. Токен будет выглядеть следующим образом в псевдокоде:
| 1 2 | content = toJSON(user_details)token = BASE64(content) + "."+ BASE64(HMAC(content)) | 
Точка в токене служит разделителем, поэтому каждая часть может быть идентифицирована и декодирована отдельно, так как символ точки не является частью какой-либо строки в кодировке base64. HMAC обозначает код аутентификации сообщений на основе хэша, который в основном представляет собой хэш, созданный из любых данных с использованием предварительно определенного секретного ключа.
В реальной Java генерация токена очень похожа на псевдокод:
создать токен
| 1 2 3 4 5 6 7 8 9 | publicString createTokenForUser(User user) {    byte[] userBytes = toJSON(user);    byte[] hash = createHmac(userBytes);    finalStringBuilder sb = newStringBuilder(170);    sb.append(toBase64(userBytes));    sb.append(SEPARATOR);    sb.append(toBase64(hash));    returnsb.toString();} | 
Соответствующими пользовательскими свойствами, используемыми в JSON, являются id, имя пользователя, срок действия и роли , но в действительности это может быть все, что вы хотите. Я пометил свойство «пароль» объекта User, которое будет игнорироваться во время сериализации JSON Джексона, чтобы оно не стало частью токена:
Игнорировать пароль
| 1 2 3 4 | @JsonIgnorepublicString getPassword() {    returnpassword;} | 
Для реальных сценариев вы, вероятно, просто хотите использовать выделенный объект для этого.
Декодирование токена немного сложнее с некоторой проверкой входных данных для предотвращения / перехвата ошибок синтаксического анализа из-за темперирования с токеном:
расшифровать токен
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 | publicUser parseUserFromToken(String token) {    finalString[] parts = token.split(SEPARATOR_SPLITTER);    if(parts.length == 2&& parts[0].length() > 0&& parts[1].length() > 0) {        try{            finalbyte[] userBytes = fromBase64(parts[0]);            finalbyte[] hash = fromBase64(parts[1]);            booleanvalidHash = Arrays.equals(createHmac(userBytes), hash);            if(validHash) {                finalUser user = fromJSON(userBytes);                if(newDate().getTime() < user.getExpires()) {                    returnuser;                }            }        } catch(IllegalArgumentException e) {            //log tampering attempt here        }    }    returnnull;} | 
По сути, он проверяет, совпадает ли предоставленный хеш с новым вычисленным хешем содержимого. Поскольку метод createHmac использует нераскрытый секретный ключ внутри для вычисления хэша, ни один клиент не сможет замерить содержимое и предоставить хеш, такой же, как тот, который будет генерировать сервер. Только после прохождения этого теста предоставленные данные будут интерпретироваться как JSON, представляющий объект User.
Увеличение масштаба части Hmac позволяет увидеть точную Java-версию. Сначала он должен быть инициализирован секретным ключом, который я делаю как часть конструктора TokenHandler:
Инициализация HMAC
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | ...privatestaticfinalString HMAC_ALGO = "HmacSHA256";privatefinalMac hmac;publicTokenHandler(byte[] secretKey) {    try{        hmac = Mac.getInstance(HMAC_ALGO);        hmac.init(newSecretKeySpec(secretKey, HMAC_ALGO));    } catch(NoSuchAlgorithmException | InvalidKeyException e) {        thrownewIllegalStateException(            "failed to initialize HMAC: "+ e.getMessage(), e);    }}... | 
После инициализации его можно (повторно) использовать, используя один вызов метода! (JavaDoc doFinal читает «Обрабатывает данный массив байтов и завершает операцию MAC. Вызов этого метода сбрасывает этот объект Mac в состояние, в котором он находился, когда он был предварительно инициализирован с помощью вызова init (Key) или init (Key, AlgorithmParameterSpec)» …»)
createHmac
| 1 2 3 4 | // synchronized to guard internal hmac objectprivatesynchronizedbyte[] createHmac(byte[] content) {    returnhmac.doFinal(content);} | 
Я использовал грубую синхронизацию, чтобы предотвратить конфликты при использовании в Spring Singleton Service. Реальный метод очень быстрый (~ 0,01 мс), поэтому он не должен вызывать проблем, если вы не наберете 10 000+ запросов в секунду на сервер.
Говоря о Сервисе, давайте перейдем к полностью работающей службе аутентификации на основе токенов:
TokenAuthenticationService
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | @ServicepublicclassTokenAuthenticationService {    privatestaticfinalString AUTH_HEADER_NAME = "X-AUTH-TOKEN";    privatestaticfinallongTEN_DAYS = 1000* 60* 60* 24* 10;    privatefinalTokenHandler tokenHandler;    @Autowired    publicTokenAuthenticationService(@Value("${token.secret}") String secret) {        tokenHandler = newTokenHandler(DatatypeConverter.parseBase64Binary(secret));    }    publicvoidaddAuthentication(HttpServletResponse response, UserAuthentication authentication) {        finalUser user = authentication.getDetails();        user.setExpires(System.currentTimeMillis() + TEN_DAYS);        response.addHeader(AUTH_HEADER_NAME, tokenHandler.createTokenForUser(user));    }    publicAuthentication getAuthentication(HttpServletRequest request) {        finalString token = request.getHeader(AUTH_HEADER_NAME);        if(token != null) {            finalUser user = tokenHandler.parseUserFromToken(token);            if(user != null) {                returnnewUserAuthentication(user);            }        }        returnnull;    }} | 
  Довольно просто, инициализируя приватный TokenHandler для выполнения тяжелой работы.  Он предоставляет методы для добавления и чтения пользовательского заголовка HTTP-токена.  Как вы можете видеть, он не использует какой-либо (управляемый базой данных) UserDetailsService для поиска сведений о пользователе.  Все детали, необходимые для того, чтобы Spring Security могла выполнять дальнейшие проверки авторизации, предоставляются с помощью токена. 
  Наконец, теперь мы можем подключить все это к Spring Security, добавив два пользовательских фильтра в конфигурацию Security: 
Конфигурация безопасности внутри StatelessAuthenticationSecurityConfig
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | ...@Overrideprotectedvoidconfigure(HttpSecurity http) throwsException {    http        ...        // custom JSON based authentication by POST of     // {"username":"<name>","password":"<password>"}     // which sets the token header upon authentication    .addFilterBefore(newStatelessLoginFilter("/api/login", ...),             UsernamePasswordAuthenticationFilter.class)    // custom Token based authentication based on     // the header previously given to the client    .addFilterBefore(newStatelessAuthenticationFilter(...),             UsernamePasswordAuthenticationFilter.class);}... | 
StatelessLoginFilter добавляет маркер после успешной аутентификации:
StatelessLoginFilter
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | ...@OverrideprotectedvoidsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,        FilterChain chain, Authentication authentication) throwsIOException, ServletException {    // Lookup the complete User object from the database and create an Authentication for it    finalUser authenticatedUser = userDetailsService.loadUserByUsername(authentication.getName());    finalUserAuthentication userAuthentication = newUserAuthentication(authenticatedUser);    // Add the custom token as HTTP header to the response    tokenAuthenticationService.addAuthentication(response, userAuthentication);    // Add the authentication to the Security context    SecurityContextHolder.getContext().setAuthentication(userAuthentication);}... | 
StatelessAuthenticationFilter просто устанавливает аутентификацию на основе заголовка:
StatelessAuthenticationFilter
| 01 02 03 04 05 06 07 08 09 10 | ...@OverridepublicvoiddoFilter(ServletRequest req, ServletResponse res, FilterChain chain)         throwsIOException, ServletException {    SecurityContextHolder.getContext().setAuthentication(            tokenAuthenticationService.getAuthentication((HttpServletRequest) req));    chain.doFilter(req, res); // always continue}... | 
Обратите внимание, что в отличие от большинства фильтров, связанных с Spring Security, я предпочитаю продолжать цепочку фильтров независимо от успешной аутентификации. Я хотел поддержать запуск Spring AnonymousAuthenticationFilter для поддержки анонимной аутентификации. Здесь большая разница в том, что фильтр не настроен для сопоставления с любым URL, специально предназначенным для аутентификации, поэтому отсутствие заголовка на самом деле не является ошибкой.
Реализация на стороне клиента
  Реализация на стороне клиента снова довольно проста.  Опять же, я сохраняю минималистичность, чтобы предотвратить потерю бита аутентификации в деталях AngularJS  Если вы ищете пример AngularJS JWT, более тщательно интегрированный с маршрутами, вам следует взглянуть здесь .  Я позаимствовал у него часть логики перехватчика. 
  Вход в систему, это просто вопрос хранения токена (в localStorage ): 
авторизоваться
| 1 2 3 4 5 6 7 | $scope.login = function () {    var credentials = { username: $scope.username, password: $scope.password };    $http.post('/api/login', credentials).success(function (result, status, headers) {        $scope.authenticated = true;        TokenStorage.store(headers('X-AUTH-TOKEN'));    });  }; | 
Выйти из системы еще проще (нет необходимости обращаться к серверу):
выйти
| 1 2 3 4 5 | $scope.logout = function () {    // Just clear the local storage    TokenStorage.clear();       $scope.authenticated = false;}; | 
Чтобы убедиться, что пользователь «уже вошел в систему», ng-init = »init ()» работает хорошо:
в этом
| 1 2 3 4 5 6 7 8 | $scope.init = function () {    $http.get('/api/users/current').success(function (user) {        if(user.username !== 'anonymousUser'){            $scope.authenticated = true;            $scope.username = user.username;        }    });}; | 
Я предпочитаю использовать анонимно достижимую конечную точку, чтобы предотвратить запуск 401/403. Вы также можете декодировать сам токен и проверить время истечения, полагая, что время локального клиента будет достаточно точным.
Наконец, чтобы автоматизировать процесс добавления заголовка, хорошо работает простой перехватчик, похожий на последний в блоге:
TokenAuthInterceptor
| 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | factory('TokenAuthInterceptor', function($q, TokenStorage) {    return{        request: function(config) {            var authToken = TokenStorage.retrieve();            if(authToken) {                config.headers['X-AUTH-TOKEN'] = authToken;            }            returnconfig;        },        responseError: function(error) {            if(error.status === 401|| error.status === 403) {                TokenStorage.clear();            }            return$q.reject(error);        }    };}).config(function($httpProvider) {    $httpProvider.interceptors.push('TokenAuthInterceptor');}); | 
Он также обеспечивает автоматическую очистку токена после получения HTTP 401 или 403, предполагая, что клиент не будет разрешать звонки в области, которые требуют более высоких привилегий.
TokenStorage
TokenStorage — это всего лишь сервис-оболочка для localStorage, и я не буду вам мешать. Помещение токена в localStorage защищает его от прочтения скриптом вне исходного скрипта, который его сохранил, как куки. Однако, поскольку токен не является настоящим Cookie, ни один браузер не может быть автоматически добавлен в запросы. Это очень важно, поскольку полностью предотвращает любые формы CSRF-атак. Таким образом, избавляя вас от необходимости реализовывать любую (безгражданскую) защиту CSRF, упомянутую в моем предыдущем блоге
- Вы можете найти полный рабочий пример с некоторыми полезными дополнениями на github .
Убедитесь, что у вас установлен gradle 2.0, и просто запустите его, используя «gradle build», а затем «gradle run». Если вы хотите поиграть с ним в вашей IDE, такой как Eclipse, используйте «gradle eclipse» и просто импортируйте и запустите его изнутри вашей IDE (сервер не нужен).
| Ссылка: | Spring State Security, часть 2: аутентификация без сохранения состояния от нашего партнера JCG Роберта ван Вейверена в блоге JDriven . |