Весняний тест та безпека: як знущатися з аутентифікації?


124

Я намагався розібратися, як зробити тест, якщо мої URL-адреси контролерів належним чином захищені. Про всяк випадок, якщо хтось змінить речі і випадково видалить налаштування безпеки.

Мій метод контролера виглядає приблизно так:

@RequestMapping("/api/v1/resource/test") 
@Secured("ROLE_USER")
public @ResonseBody String test() {
    return "test";
}

Я налаштував WebTestEnvironment так:

import javax.annotation.Resource;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.junit.Before;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ 
        "file:src/main/webapp/WEB-INF/spring/security.xml",
        "file:src/main/webapp/WEB-INF/spring/applicationContext.xml",
        "file:src/main/webapp/WEB-INF/spring/servlet-context.xml" })
public class WebappTestEnvironment2 {

    @Resource
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    @Qualifier("databaseUserService")
    protected UserDetailsService userDetailsService;

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    protected DataSource dataSource;

    protected MockMvc mockMvc;

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    protected UsernamePasswordAuthenticationToken getPrincipal(String username) {

        UserDetails user = this.userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                        user, 
                        user.getPassword(), 
                        user.getAuthorities());

        return authentication;
    }

    @Before
    public void setupMockMvc() throws NamingException {

        // setup mock MVC
        this.mockMvc = MockMvcBuilders
                .webAppContextSetup(this.wac)
                .addFilters(this.springSecurityFilterChain)
                .build();
    }
}

У своєму фактичному тесті я намагався зробити щось подібне:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class CopyOfClaimTest extends WebappTestEnvironment {

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        SecurityContextHolder.getContext().setAuthentication(principal);        

        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
//                    .principal(principal)
                    .session(session))
            .andExpect(status().isOk());
    }

}

Я взяв це тут:

Але якщо уважно придивитися, це допомагає лише при надсиланні фактичних запитів до URL-адрес, але лише при тестуванні служб на функціональному рівні. У моєму випадку було викинуто виняток "відмовлено у доступі":

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:83) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:206) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:60) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:172) ~[spring-aop-3.2.1.RELEASE.jar:3.2.1.RELEASE]
        ...

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

14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Secure object: ReflectiveMethodInvocation: public java.util.List test.TestController.test(); target is of class [test.TestController]; Attributes: [ROLE_USER]
14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055e4a6: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS

Назва компанії, eu.ubicon, відображається у вашому імпорті. Це не ризик для безпеки?
Kyle Bridenstine

2
Привіт, дякую за коментар! Не можу зрозуміти, чому все-таки. Все одно це програмне забезпечення з відкритим кодом. Якщо ви зацікавлені, перегляньте бітбукет.org/ubicon/ubicon (або бітбукет.org/dmir_wue/everyaware для останньої вилки). Дайте мені знати, якщо я щось пропускаю.
Мартін Бекер

Перевірте це рішення (відповідь - весна 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Відповіді:


101

Вишукуючи відповідь, я не міг знайти одночасно легку та гнучку, тоді я знайшов Spring Spring Reference і зрозумів, що є близькі до ідеальних рішень. Рішення AOP часто є найбільшими для тестування, і Spring надає це @WithMockUser, @WithUserDetailsі @WithSecurityContextв цьому артефакті:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <version>4.2.2.RELEASE</version>
    <scope>test</scope>
</dependency>

У більшості випадків @WithUserDetailsзбирає потрібну мені гнучкість та потужність.

Як працює @WithUserDetails?

По суті, вам просто потрібно створити спеціальний UserDetailsServiceпрофіль із усіма можливими профілями користувачів, які ви хочете протестувати. Напр

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "user@company.com", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "manager@company.com", "password");
        UserActive managerActiveUser = new UserActive(managerUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_MANAGER"),
                new SimpleGrantedAuthority("PERM_FOO_READ"),
                new SimpleGrantedAuthority("PERM_FOO_WRITE"),
                new SimpleGrantedAuthority("PERM_FOO_MANAGE")
        ));

        return new InMemoryUserDetailsManager(Arrays.asList(
                basicActiveUser, managerActiveUser
        ));
    }
}

Тепер у нас є користувачі готові, тому уявімо, що ми хочемо перевірити контроль доступу до цієї функції контролера:

@RestController
@RequestMapping("/foo")
public class FooController {

    @Secured("ROLE_MANAGER")
    @GetMapping("/salute")
    public String saluteYourManager(@AuthenticationPrincipal User activeUser)
    {
        return String.format("Hi %s. Foo salutes you!", activeUser.getUsername());
    }
}

Тут ми маємо функцію зіставлення карти на маршрут / foo / salute, і ми тестуємо безпеку на основі ролі з @Securedанотацією, хоча ви можете перевірити @PreAuthorizeі @PostAuthorizeце. Створимо два тести: один перевірить, чи дійсний користувач може побачити цю салютну відповідь, а другий - перевірити, чи дійсно це заборонено.

@RunWith(SpringRunner.class)
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = SpringSecurityWebAuxTestConfig.class
)
@AutoConfigureMockMvc
public class WebApplicationSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails("manager@company.com")
    public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("manager@company.com")));
    }

    @Test
    @WithUserDetails("user@company.com")
    public void givenBasicUser_whenGetFooSalute_thenForbidden() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isForbidden());
    }
}

Як ви бачите, ми імпортували, SpringSecurityWebAuxTestConfigщоб надати користувачам тестування. Кожен з них використовувався у відповідному тестовому випадку лише за допомогою прямого анотації, зменшення коду та складності.

Краще використовувати @WithMockUser для більш простої рольової безпеки

Як бачите, у вас @WithUserDetailsє вся гнучкість, необхідна для більшості ваших програм. Це дозволяє використовувати користувацьких користувачів з будь-яким GrantedAuthority, як-от ролі або дозволи. Але якщо ви просто працюєте з ролями, тестування може бути ще простіше, і ви можете уникнути побудови замовника UserDetailsService. У таких випадках вкажіть просту комбінацію користувача, пароля та ролей за допомогою @WithMockUser .

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(
    factory = WithMockUserSecurityContextFactory.class
)
public @interface WithMockUser {
    String value() default "user";

    String username() default "";

    String[] roles() default {"USER"};

    String password() default "password";
}

Анотація визначає значення за замовчуванням для дуже базового користувача. Оскільки в нашому випадку маршрут, який ми тестуємо, просто вимагає, щоб автентифікований користувач був менеджером, ми можемо вийти з цього SpringSecurityWebAuxTestConfigі зробити це.

@Test
@WithMockUser(roles = "MANAGER")
public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
{
    mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
            .accept(MediaType.ALL))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("user")));
}

Зауважте, що зараз замість user manager@company.com ми отримуємо стандартні типи @WithMockUser: user ; але це не буде мати значення , тому що ми дійсно дбаємо про його ролі: ROLE_MANAGER.

Висновки

Як ви бачите , з примітками , як @WithUserDetailsі @WithMockUserми можемо перемикатися між різними сценаріями користувачів , які пройшли перевіркою без створення класів відчужених від нашої архітектури тільки для створення простих тестів. Також рекомендується вам подивитися, як працює @WithSecurityContext для ще більшої гнучкості.


Як знущатися над кількома користувачами ? Наприклад, перший запит надсилається tom, а другий - jerry?
ch271828n

Ви можете створити функцію, де ваш тест з tom і створити інший тест з тією ж логікою і протестувати його з Джеррі. Для кожного тесту буде визначений конкретний результат, тому будуть різні твердження, і якщо тест не вдасться, він скаже вам за своїм ім'ям, який користувач / роль не працював. Пам'ятайте, що у запиті користувач може бути лише одним, тому вказувати кілька користувачів у запиті не має сенсу.
EliuX

Вибачте, я маю на увазі такий приклад сценарію: ми перевіряємо це, Том створює секретну статтю, потім Джеррі намагається це прочитати, і Джеррі не повинен бачити це (оскільки це секретно). Тож у цьому випадку це одна одинична проба ...
ch271828n

Це виглядає так , як BasicUserі Manager Userсценарій , зазначений у відповіді. Ключова концепція полягає в тому, що замість того, щоб дбати про користувачів, ми насправді піклуємося про їх ролі, але кожен з цих тестів, розташований в одному тесті, фактично представляє різні запити. виконується різними користувачами (з різними ролями) до однієї кінцевої точки.
EliuX

61

Починаючи з весни 4.0+, найкращим рішенням є анотація методу тестування за допомогою @WithMockUser

@Test
@WithMockUser(username = "user1", password = "pwd", roles = "USER")
public void mytest1() throws Exception {
    mockMvc.perform(get("/someApi"))
        .andExpect(status().isOk());
}

Не забудьте додати в проект наступну залежність

'org.springframework.security:spring-security-test:4.2.3.RELEASE'

1
Весна дивовижна. Спасибі
TuGordoBello

Хороша відповідь. Більше того - вам не потрібно використовувати mockMvc, але у випадку, якщо ви використовуєте, наприклад, PagingAndSortingRepository з springframework.data - ви можете просто зателефонувати до методів із сховища безпосередньо (які позначаються за допомогою EL @PreAuthorize (......))
суперрамба

50

Виявилося, що SecurityContextPersistenceFilter, що є частиною ланцюга фільтрів Spring Security, завжди скидає моє SecurityContext, яке я встановив SecurityContextHolder.getContext().setAuthentication(principal)(або за допомогою .principal(principal)методу). Цей фільтр встановлює значення SecurityContext" SecurityContextHoldera" SecurityContextз " SecurityContextRepository НАДОБРЕНОГО ", який я встановив раніше. Сховище HttpSessionSecurityContextRepositoryза замовчуванням. У HttpSessionSecurityContextRepositoryінспектує дані HttpRequestі намагаюся отримати доступ до відповідного HttpSession. Якщо він існує, він спробує прочитати повідомлення SecurityContextз HttpSession. Якщо це не вдалося, сховище генерує порожнє SecurityContext.

Таким чином, моє рішення полягає в тому, щоб пройти HttpSessionразом із запитом, який містить SecurityContext:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class Test extends WebappTestEnvironment {

    public static class MockSecurityContext implements SecurityContext {

        private static final long serialVersionUID = -1386535243513362694L;

        private Authentication authentication;

        public MockSecurityContext(Authentication authentication) {
            this.authentication = authentication;
        }

        @Override
        public Authentication getAuthentication() {
            return this.authentication;
        }

        @Override
        public void setAuthentication(Authentication authentication) {
            this.authentication = authentication;
        }
    }

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        MockHttpSession session = new MockHttpSession();
        session.setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, 
                new MockSecurityContext(principal));


        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
                    .session(session))
            .andExpect(status().isOk());
    }
}

2
Ми ще не додали офіційної підтримки Spring Security. Див. Jira.springsource.org/browse/SEC-2015 Опис того, як це буде виглядати, зазначено в github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Роб Вінч

Я не думаю, що створення об’єкта аутентифікації та додавання сеансу з відповідним атрибутом - це погано. Як ви вважаєте, це дійсна «робота навколо»? З іншого боку, пряма підтримка була б чудовою. Виглядає досить акуратно. Дякуємо за посилання!
Мартін Бекер

чудове рішення. працював на мене! лише незначна проблема з називанням захищеного методу, getPrincipal()який, на мою думку, трохи вводить в оману. в ідеалі це мало бути названо getAuthentication(). так само у вашому signedIn()тесті локальна змінна повинна бути названа authабо authenticationзамістьprincipal
Tanvir

Що таке "getPrincipal (" test1 ")? Чи можете ви пояснити, де це? Дякую заздалегідь
user2992476

@ user2992476 Можливо, він повертає об'єкт типу UsernamePasswordAuthenticationToken. Крім того, ви створюєте GrantedAuthority і будуєте цей об'єкт.
bluelurker

31

Додати в pom.xml:

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <version>4.0.0.RC2</version>
    </dependency>

і використовувати org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorsдля запиту авторизації. Дивіться приклад використання на веб- сайті https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Оновлення:

4.0.0.RC2 працює на захист пружини 3.x. Для Spring-security 4 тест безпеки для весни став частиною безпеки для весни ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , версія однакова ).

Налаштування змінено: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test-mockmvc

public void setup() {
    mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())  
            .build();
}

Зразок базової автентифікації: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .


Це також вирішило мою проблему з отриманням 404 при спробі входу через фільтр безпеки входу. Дякую!
Ян Ньюленд

Привіт, під час тестування, як згадував GKislin. Я отримую наступну помилку "Не вдалося пройти автентифікацію. UserDetailsService повернув нуль, що є порушенням договору інтерфейсу". Будь-яка пропозиція, будь ласка. остаточний AuthenticationRequest auth = новий AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (пароль); mockMvc.perform (post ("/ api / auth /"). content (json (auth)). contentType (MediaType.APPLICATION_JSON));
Саньєєв

7

Ось приклад для тих, хто хоче протестувати Spring MockMvc Security Config за допомогою базової автентифікації Base64.

String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64(("<username>:<password>").getBytes()));
this.mockMvc.perform(get("</get/url>").header("Authorization", basicDigestHeaderValue).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk());

Maven залежність

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>

3

Коротка відповідь:

@Autowired
private WebApplicationContext webApplicationContext;

@Autowired
private Filter springSecurityFilterChain;

@Before
public void setUp() throws Exception {
    final MockHttpServletRequestBuilder defaultRequestBuilder = get("/dummy-path");
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
            .defaultRequest(defaultRequestBuilder)
            .alwaysDo(result -> setSessionBackOnRequestBuilder(defaultRequestBuilder, result.getRequest()))
            .apply(springSecurity(springSecurityFilterChain))
            .build();
}

private MockHttpServletRequest setSessionBackOnRequestBuilder(final MockHttpServletRequestBuilder requestBuilder,
                                                             final MockHttpServletRequest request) {
    requestBuilder.session((MockHttpSession) request.getSession());
    return request;
}

Після виконання formLoginвесняного тесту на безпеку кожен ваш запит буде автоматично викликаний як зареєстрований користувач.

Довга відповідь:

Перевірте це рішення (відповідь - весна 4): Як увійти до користувача за допомогою весни 3.2 нового тестування mvc


2

Варіанти, щоб не використовувати SecurityContextHolder у тестах:

  • Варіант 1 : використовувати макети - я маю на увазі макет SecurityContextHolderза допомогою деякої макетної бібліотеки - наприклад, EasyMock
  • Варіант 2 : заверніть виклик SecurityContextHolder.get...свого коду в якійсь службі - наприклад, в SecurityServiceImplметоді, getCurrentPrincipalякий реалізує SecurityServiceінтерфейс, а потім у ваших тестах ви можете просто створити макетну реалізацію цього інтерфейсу, який повертає потрібний принцип без доступу до SecurityContextHolder.

Гм, можливо, я не розумію всієї картини. Моя проблема полягала в тому, що SecurityContextPersistenceFilter замінює SecurityContext за допомогою SecurityContext з HttpSessionSecurityContextRepository, який, в свою чергу, зчитує SecurityContext з відповідного HttpSession. Таким чином рішення за допомогою сеансу. Стосовно виклику до SecurityContextHolder: я змінив свою відповідь так, що більше не використовую виклик до SecurityContextHolder. Але також не вводячи жодних обгорткових або зайвих глузуючих бібліотек. Як ви думаєте, це краще рішення?
Мартін Бекер

Вибачте, що я не зрозумів, що саме ви шукали, і я не можу надати кращої відповіді, ніж рішення, яке ви знайшли, і - здається, це хороший варіант.
Павла Новакова

Гаразд, дякую. Я зараз прийму свою пропозицію як рішення.
Мартін Беккер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.