Аутентификация по HTTP-заголовку в Spring Boot

Аутентификация по http-заголовкку

Введение

В этой статье будет рассказано, как реализовать аутентификацию пользователя в Spring Boot-приложении на основе HTTP-заголовка.

Когда аутентификацию пользователя выполняет приложение, которое Вы разрабатываете (например, на основе пары логин-пароль), можно легко отличить анонимного и залогинившегося пользователя. Для этого достаточнго обратиться к SecurityContextHolder.

Однако всё меняется, если аутентификация пользователей производится в другом приложении и единственным признаком того, что пользователь выполнил вход, является наличие определённого заголовка в HTTP-запросах, которые приходят в Вашу программу.

В таком варианте у разработчика есть как минимум два варианта, чтобы определить был ли пользователем выполнен вход.
Путь номер один – каждый раз проверять наличие заголовка (например, через RequestContextHolder).
Путь номер два – сделать так, чтобы информация о выполненной аутентификации появилась в Security Context – это даст возможность использовать стандартные средства авторизации Spring Security, например такие, как аннотация @PreAuthorize. Именно о втором пути и пойдёт речь дальше.

Создание конфигурации

Spring Security – это по сути набор фильтров (javax.servlet.Filter), чем мы и воспользумся, добавив нужный фильтр – RequestHeaderAuthenticationFilter.

Для начала нам необходимо создать класс конфигурации и переопределить в нём метод configure(HttpSecurity http):

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().permitAll()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                .and()
                .addFilterAfter(requestHeaderAuthenticationFilter(), LogoutFilter.class)
                .authenticationProvider(preAuthenticatedAuthenticationProvider());
    }
}

Основные моменты, на которые нужно обратить внимание – это строки
.addFilterAfter(requestHeaderAuthenticationFilter(), LogoutFilter.class) и
.authenticationProvider(preAuthenticatedAuthenticationProvider())
Первая добавляет нужный нам фильтр, а вторая задаёт соответствующий Authentication Provider.
Давайте их опишем:

    public RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter() throws Exception {
        RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter();
        filter.setPrincipalRequestHeader("principal");
        filter.setAuthenticationManager(authenticationManagerBean());
        filter.setExceptionIfHeaderMissing(false);
        filter.setCheckForPrincipalChanges(true);
        return filter;
    }

В setPrincipalRequestHeader указывается имя HTTP-заголовка, который установлен, если пользователь выполнил вход.
В setAuthenticationManager указывается Authentication Manager нашего приложения. В качестве аргумента вызовем метод родительского класса authenticationManagerBean().
В setExceptionIfHeaderMissing мы передаём false, чтобы пользователь мог обращаться к тем разделам (url), для которых разрешён анонимный доступ. Если все разделы должны быть доступны только для аутентифицированных пользователей, то эта строка не нужна.
setCheckForPrincipalChanges(true) заставляет выполнять проверку заголовка для каждого запроса.

Далее создадим AuthenticationProvider.

    public PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider() {
        PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
        provider.setPreAuthenticatedUserDetailsService(userDetailsByNameServiceWrapper());
        return provider;
    }

В состав Spring Security уже входит класс, который нам подходит – это PreAuthenticatedAuthenticationProvider. Единственное, что требуется от нас – предоставить ему класс, реализующий интерфейс AuthenticationUserDetailsService. Можно воспользоваться готовой реализацией – UserDetailsByNameServiceWrapper:

    public UserDetailsByNameServiceWrapper userDetailsByNameServiceWrapper() {
        UserDetailsByNameServiceWrapper<PreAuthenticatedAuthenticationToken> serviceWrapper = new UserDetailsByNameServiceWrapper<>();
        serviceWrapper.setUserDetailsService(customUserDetailsService());
        return serviceWrapper;
    }

    public UserDetailsService customUserDetailsService() {
        return username -> User.withUsername(username)
                .password("")
                .roles("USER")
                .build();
    }

Для упрощения нашего примера UserDetailsService реализован очень просто. Разумеется, в реальном приложении Вам нужно будет реализовать его как следует, например считывать информацию о пользователе из базы данных.

Создание контроллера

Для проверки нашей конфигурации создадим REST-контроллер с двумя методами: один будет доступен всем пользователям, в нём будет определяться имя текущего пользователя из заголовка “principal”, а второй будет доступен только для пользователей, выполнивших вход:

@RestController
public class MainController {

    @GetMapping("")
    public ResponseEntity<String> hello() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return ResponseEntity.ok("Hello, " + (authentication == null ? "Anonymous" : authentication.getName()));
    }

    @GetMapping("/profile")
    @PreAuthorize("hasRole('ROLE_USER')")
    public ResponseEntity<String> profile() {
        return ResponseEntity.ok("Hello From Restricted Area");
    }
}

Запустим наше приложение и выполним команду curl без заголовка principal:

curl localhost:8080

Ответ:

Hello, anonymousUser

А теперь попробуем открыть URL, который должен быть доступен только после аутентификации, то есть если есть заголовок principal. Для наглядности добавим в curl параметр -v:

curl -v localhost:8080

Ответ:

*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /profile HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> 
< HTTP/1.1 401 
< Set-Cookie: JSESSIONID=9284CC9D258871FDDF792C08F37E72C9; Path=/; HttpOnly
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 0
< Date: Sun, 01 Mar 2020 19:42:30 GMT
< 
* Connection #0 to host localhost left intact

Как видно в строке 8, сервер вернул код 401 UNAUTHORIZED, как и должно быть.

Теперь выполним те же запросы с заголовком principal=John Doe:

curl -H 'principal: John Doe' localhost:8080

Ответ:

Hello, John Doe

И второй запрос:

curl -H 'principal: John Doe' localhost:8080/profile

Ответ:

Hello From Restricted Area

Заключение

В данной статье мы рассмотрели способ аутентификации пользователя по HTTP-заголовку. Код проекта доступен на github.com.