Статьи

Embedded Jetty и Apache CXF: безопасные службы REST с помощью Spring Security

Недавно я столкнулся с очень интересной проблемой, которая, как я думал, займет у меня всего пару минут: защита   служб Apache CXF  (текущий выпуск  3.0.1 ) /  JAX-RS  REST с помощью  Spring Security  (текущая стабильная версия  3.2.5 ) в приложение работает внутри встроенного   контейнера Jetty (текущий выпуск  9.2 ). В конце концов, это оказывается очень легко, когда вы понимаете, как все работает вместе, и узнаете тонкие внутренние детали. Этот пост будет пытаться раскрыть это.

В нашем примере приложения будет представлен простой   сервис JAX-RS  /  REST для управления людьми. Однако мы не хотим, чтобы всем было разрешено делать это, поэтому   для доступа к нашей конечной точке, развернутой по адресу http: // localhost: 8080 / api / rest / people, потребуется  базовая аутентификация HTTP . Давайте посмотрим на  класс PeopleRestService :

package com.example.rs;

import javax.json.Json;
import javax.json.JsonArray;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;

@Path( "/people" ) 
public class PeopleRestService {
    @Produces( { "application/json" } )
    @GET
    public JsonArray getPeople() {
        return Json.createArrayBuilder()
            .add( Json.createObjectBuilder()
                .add( "firstName", "Tom" )
                .add( "lastName", "Tommyknocker" )
                .add( "email", "a@b.com" ) )
            .build();
    }
}

Как вы можете видеть из приведенного выше фрагмента, ничто не указывает на тот факт, что эта   служба REST защищена, всего лишь пара знакомых   аннотаций JAX-RS .

Теперь давайте объявим желаемую конфигурацию безопасности, следуя отличной  документации Spring Security . Существует множество способов настройки  Spring Security,  но мы собираемся показать два из них: использование аутентификации в памяти и использование службы сведений о пользователе, оба построены на основе  WebSecurityConfigurerAdapter . Давайте начнем с аутентификации в памяти, так как она самая простая:

package com.example.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity( securedEnabled = true )
public class InMemorySecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser( "user" ).password( "password" ).roles( "USER" ).and()
            .withUser( "admin" ).password( "password" ).roles( "USER", "ADMIN" );
    }

    @Override
    protected void configure( HttpSecurity http ) throws Exception {
        http.httpBasic().and()
            .sessionManagement().sessionCreationPolicy( SessionCreationPolicy.STATELESS ).and()
            .authorizeRequests().antMatchers("/**").hasRole( "USER" );
    }
}

В приведенном выше фрагменте определены два пользователя:  пользователь  с ролью  USER  и  администратор  с ролями  USER , ADMIN . Мы также защищаем все URL ( / ** ), устанавливая политику авторизации, чтобы разрешить доступ только пользователям с ролью USER . Будучи лишь частью конфигурации приложения, давайте подключим его к   классу AppConfig с  помощью аннотации @Import .

package com.example.config;

import java.util.Arrays;

import javax.ws.rs.ext.RuntimeDelegate;

import org.apache.cxf.bus.spring.SpringBus;
import org.apache.cxf.endpoint.Server;
import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
import org.apache.cxf.jaxrs.provider.jsrjsonp.JsrJsonpProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Import;

import com.example.rs.JaxRsApiApplication;
import com.example.rs.PeopleRestService;

@Configuration
@Import( InMemorySecurityConfig.class )
public class AppConfig { 
    @Bean( destroyMethod = "shutdown" )
    public SpringBus cxf() {
        return new SpringBus();
    }

    @Bean @DependsOn ( "cxf" )
    public Server jaxRsServer() {
        JAXRSServerFactoryBean factory = RuntimeDelegate.getInstance().createEndpoint( jaxRsApiApplication(), JAXRSServerFactoryBean.class );
        factory.setServiceBeans( Arrays.< Object >asList( peopleRestService() ) );
        factory.setAddress( factory.getAddress() );
        factory.setProviders( Arrays.< Object >asList( new JsrJsonpProvider() ) );
        return factory.create();
    }

    @Bean 
    public JaxRsApiApplication jaxRsApiApplication() {
        return new JaxRsApiApplication();
    }

    @Bean 
    public PeopleRestService peopleRestService() {
        return new PeopleRestService();
    }  
}

На данный момент у нас есть все части, кроме самого интересного: кода, который запускает встроенный  экземпляр Jetty и создает надлежащие отображения сервлетов, слушателей, передавая конфигурацию, которую мы создали.

package com.example;

import java.util.EnumSet;

import javax.servlet.DispatcherType;

import org.apache.cxf.transport.servlet.CXFServlet;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.springframework.web.context.ContextLoaderListener;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.filter.DelegatingFilterProxy;

import com.example.config.AppConfig;

public class Starter {
    public static void main( final String[] args ) throws Exception {
        Server server = new Server( 8080 );

        // Register and map the dispatcher servlet
        final ServletHolder servletHolder = new ServletHolder( new CXFServlet() );
        final ServletContextHandler context = new ServletContextHandler();   
        context.setContextPath( "/" );
        context.addServlet( servletHolder, "/rest/*" );  
        context.addEventListener( new ContextLoaderListener() );

        context.setInitParameter( "contextClass", AnnotationConfigWebApplicationContext.class.getName() );
        context.setInitParameter( "contextConfigLocation", AppConfig.class.getName() );

        // Add Spring Security Filter by the name
        context.addFilter(
            new FilterHolder( new DelegatingFilterProxy( "springSecurityFilterChain" ) ), 
                "/*", EnumSet.allOf( DispatcherType.class )
        );

        server.setHandler( context );
        server.start();
        server.join(); 
    }
}

Большая часть кода не требует каких-либо объяснений, кроме части фильтра. Это то, что я имел в виду под тонкими внутренними деталями:  DelegatingFilterProxy  должен быть настроен с именем фильтра, которое должно быть точно springSecurityFilterChain , как   его называет Spring Security . При этом настроенные нами правила безопасности будут применяться к любому   служебному вызову JAX-RS (фильтр безопасности выполняется перед   сервлетом Apache CXF ), требующим полной аутентификации. Давайте быстро проверим это, собрав и запустив проект:

mvn clean package   
java -jar target/jax-rs-2.0-spring-security-0.0.1-SNAPSHOT.jar

Вызов  HTTP  GET  без указания имени пользователя и пароля не выполняется и возвращает код состояния HTTP  401 .

> curl -i http://localhost:8080/rest/api/people

HTTP/1.1 401 Full authentication is required to access this resource
WWW-Authenticate: Basic realm="Realm"
Cache-Control: must-revalidate,no-cache,no-store
Content-Type: text/html; charset=ISO-8859-1
Content-Length: 339
Server: Jetty(9.2.2.v20140723)

Тот же  HTTP-   вызов GET с предоставленными именем пользователя и паролем возвращает успешный ответ (с некоторым JSON, сгенерированным сервером).

> curl -i -u user:password http://localhost:8080/rest/api/people

HTTP/1.1 200 OK
Date: Sun, 28 Sep 2014 20:07:35 GMT
Content-Type: application/json
Content-Length: 65
Server: Jetty(9.2.2.v20140723)

[{"firstName":"Tom","lastName":"Tommyknocker","email":"a@b.com"}]

Отлично, это работает как шарм! Оказывается, это действительно очень легко. Кроме того, как было упомянуто ранее, аутентификация в памяти может быть заменена службой подробностей пользователя, вот пример, как это можно сделать:

package com.example.config;

import java.util.Arrays;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class UserDetailsSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService( userDetailsService() );
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername( final String username ) 
                    throws UsernameNotFoundException {
                if( username.equals( "admin" ) ) {
                    return new User( username, "password", true, true, true, true,
                        Arrays.asList(
                            new SimpleGrantedAuthority( "ROLE_USER" ),
                            new SimpleGrantedAuthority( "ROLE_ADMIN" )
                        )
                    );
                } else if ( username.equals( "user" ) ) {
                    return new User( username, "password", true, true, true, true,
                        Arrays.asList(
                            new SimpleGrantedAuthority( "ROLE_USER" )
                        )
                    );
                } 

                return null;
            }
        };
    }

    @Override
    protected void configure( HttpSecurity http ) throws Exception {
        http
           .httpBasic().and()
           .sessionManagement().sessionCreationPolicy( SessionCreationPolicy.STATELESS ).and()
           .authorizeRequests().antMatchers("/**").hasRole( "USER" );
    }
}

Замена  @Import (InMemorySecurityConfig.class)  на  @Import (UserDetailsSecurityConfig.class)  в   классе AppConfig приводит к одинаковым результатам, поскольку обе конфигурации безопасности определяют идентичные наборы пользователей и их роли.

Надеюсь, этот пост сэкономит вам время и даст хорошую отправную точку, поскольку  Apache CXF  и  Spring Security  очень хорошо уживаются под   зонтиком Jetty !

Полный исходный код доступен на  GitHub .