Статьи

Spring State Security, часть 2: аутентификация без сохранения состояния

Эта вторая часть серии 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
public String createTokenForUser(User user) {
    byte[] userBytes = toJSON(user);
    byte[] hash = createHmac(userBytes);
    final StringBuilder sb = new StringBuilder(170);
    sb.append(toBase64(userBytes));
    sb.append(SEPARATOR);
    sb.append(toBase64(hash));
    return sb.toString();
}

Соответствующими пользовательскими свойствами, используемыми в JSON, являются id, имя пользователя, срок действия и роли , но в действительности это может быть все, что вы хотите. Я пометил свойство «пароль» объекта User, которое будет игнорироваться во время сериализации JSON Джексона, чтобы оно не стало частью токена:

Игнорировать пароль

1
2
3
4
@JsonIgnore
public String getPassword() {
    return password;
}

Для реальных сценариев вы, вероятно, просто хотите использовать выделенный объект для этого.

Декодирование токена немного сложнее с некоторой проверкой входных данных для предотвращения / перехвата ошибок синтаксического анализа из-за темперирования с токеном:

расшифровать токен

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public User parseUserFromToken(String token) {
    final String[] parts = token.split(SEPARATOR_SPLITTER);
    if (parts.length == 2 && parts[0].length() > 0 && parts[1].length() > 0) {
        try {
            final byte[] userBytes = fromBase64(parts[0]);
            final byte[] hash = fromBase64(parts[1]);
 
            boolean validHash = Arrays.equals(createHmac(userBytes), hash);
            if (validHash) {
                final User user = fromJSON(userBytes);
                if (new Date().getTime() < user.getExpires()) {
                    return user;
                }
            }
        } catch (IllegalArgumentException e) {
            //log tampering attempt here
        }
    }
    return null;
}

По сути, он проверяет, совпадает ли предоставленный хеш с новым вычисленным хешем содержимого. Поскольку метод createHmac использует нераскрытый секретный ключ внутри для вычисления хэша, ни один клиент не сможет замерить содержимое и предоставить хеш, такой же, как тот, который будет генерировать сервер. Только после прохождения этого теста предоставленные данные будут интерпретироваться как JSON, представляющий объект User.

Увеличение масштаба части Hmac позволяет увидеть точную Java-версию. Сначала он должен быть инициализирован секретным ключом, который я делаю как часть конструктора TokenHandler:

Инициализация HMAC

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
...
private static final String HMAC_ALGO = "HmacSHA256";
 
private final Mac hmac;
 
public TokenHandler(byte[] secretKey) {
    try {
        hmac = Mac.getInstance(HMAC_ALGO);
        hmac.init(new SecretKeySpec(secretKey, HMAC_ALGO));
    } catch (NoSuchAlgorithmException | InvalidKeyException e) {
        throw new IllegalStateException(
            "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 object
private synchronized byte[] createHmac(byte[] content) {
    return hmac.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
@Service
public class TokenAuthenticationService {
 
    private static final String AUTH_HEADER_NAME = "X-AUTH-TOKEN";
    private static final long TEN_DAYS = 1000 * 60 * 60 * 24 * 10;
 
    private final TokenHandler tokenHandler;
 
    @Autowired
    public TokenAuthenticationService(@Value("${token.secret}") String secret) {
        tokenHandler = new TokenHandler(DatatypeConverter.parseBase64Binary(secret));
    }
 
    public void addAuthentication(HttpServletResponse response, UserAuthentication authentication) {
        final User user = authentication.getDetails();
        user.setExpires(System.currentTimeMillis() + TEN_DAYS);
        response.addHeader(AUTH_HEADER_NAME, tokenHandler.createTokenForUser(user));
    }
 
    public Authentication getAuthentication(HttpServletRequest request) {
        final String token = request.getHeader(AUTH_HEADER_NAME);
        if (token != null) {
            final User user = tokenHandler.parseUserFromToken(token);
            if (user != null) {
                return new UserAuthentication(user);
            }
        }
        return null;
    }
}

Довольно просто, инициализируя приватный 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
...
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
     
    ...
     
    // custom JSON based authentication by POST of
    // {"username":"<name>","password":"<password>"}
    // which sets the token header upon authentication
    .addFilterBefore(new StatelessLoginFilter("/api/login", ...),
            UsernamePasswordAuthenticationFilter.class)
 
    // custom Token based authentication based on
    // the header previously given to the client
    .addFilterBefore(new StatelessAuthenticationFilter(...),
            UsernamePasswordAuthenticationFilter.class);
}
...

StatelessLoginFilter добавляет маркер после успешной аутентификации:

StatelessLoginFilter

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
...
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
        FilterChain chain, Authentication authentication) throws IOException, ServletException {
 
    // Lookup the complete User object from the database and create an Authentication for it
    final User authenticatedUser = userDetailsService.loadUserByUsername(authentication.getName());
    final UserAuthentication userAuthentication = new UserAuthentication(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
...
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, 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;
            }
            return config;
        },
        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 .