Unit testing dengan Spring Security

140

Perusahaan saya telah mengevaluasi Spring MVC untuk menentukan apakah kami harus menggunakannya di salah satu proyek kami berikutnya. Sejauh ini saya menyukai apa yang saya lihat, dan sekarang saya melihat pada modul Spring Security untuk menentukan apakah itu sesuatu yang bisa / harus kita gunakan.

Persyaratan keamanan kami sangat mendasar; pengguna hanya perlu dapat memberikan nama pengguna dan kata sandi untuk dapat mengakses bagian-bagian tertentu dari situs (seperti untuk mendapatkan info tentang akun mereka); dan ada beberapa halaman di situs (FAQ, Dukungan, dll) di mana pengguna anonim harus diberi akses.

Dalam prototipe yang saya buat, saya telah menyimpan objek "LoginCredentials" (yang hanya berisi nama pengguna dan kata sandi) di Session untuk pengguna yang diautentikasi; beberapa pengendali memeriksa untuk melihat apakah objek ini dalam sesi untuk mendapatkan referensi ke nama pengguna yang masuk, misalnya. Saya ingin mengganti logika buatan sendiri ini dengan Spring Security, yang akan bermanfaat untuk menghapus segala jenis "bagaimana cara melacak pengguna yang masuk?" dan "bagaimana kami mengautentikasi pengguna?" dari controller / kode bisnis saya.

Sepertinya Spring Security menyediakan objek "konteks" (per-utas) untuk dapat mengakses info nama pengguna / utama dari mana saja di aplikasi Anda ...

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

... yang tampaknya sangat tidak Spring seperti objek ini adalah singleton (global).

Pertanyaan saya adalah ini: jika ini adalah cara standar untuk mengakses informasi tentang pengguna terotentikasi di Spring Security, apa cara yang diterima untuk menyuntikkan objek Otentikasi ke dalam SecurityContext sehingga tersedia untuk pengujian unit saya ketika tes unit membutuhkan pengguna terautentikasi?

Apakah saya perlu menghubungkan ini dalam metode inisialisasi setiap test case?

protected void setUp() throws Exception {
    ...
    SecurityContextHolder.getContext().setAuthentication(
        new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
    ...
}

Ini sepertinya terlalu bertele-tele. Apakah ada cara yang lebih mudah?

The SecurityContextHolderobyek itu sendiri tampaknya sangat un-Spring-seperti ...

matt b
sumber

Jawaban:

48

Masalahnya adalah bahwa Spring Security tidak membuat objek Otentikasi tersedia sebagai kacang di wadah, jadi tidak ada cara untuk dengan mudah menyuntikkan atau autowire keluar dari kotak.

Sebelum kami mulai menggunakan Spring Security, kami akan membuat kacang scoped sesi dalam wadah untuk menyimpan Kepala Sekolah, menyuntikkan ini ke "AuthenticationService" (tunggal) dan kemudian menyuntikkan kacang ini ke layanan lain yang membutuhkan pengetahuan Kepala Sekolah saat ini.

Jika Anda menerapkan layanan otentikasi Anda sendiri, pada dasarnya Anda bisa melakukan hal yang sama: membuat kacang scoped sesi dengan properti "utama", menyuntikkan ini ke layanan otentikasi Anda, meminta layanan auth mengatur properti pada auth yang sukses, dan kemudian membuat layanan auth tersedia untuk kacang lain sesuai kebutuhan Anda.

Saya tidak akan merasa terlalu buruk tentang menggunakan SecurityContextHolder. meskipun. Saya tahu bahwa itu statis / Singleton dan Spring tidak menyarankan menggunakan hal-hal seperti itu tetapi implementasinya berhati-hati untuk berperilaku sesuai dengan lingkungannya: tercakup dalam sesi dalam wadah Servlet, ditutup dengan benang dalam uji JUnit, dll. Faktor pembatas nyata Singleton adalah ketika menyediakan implementasi yang tidak fleksibel untuk lingkungan yang berbeda.

cliff.meyers
sumber
Terima kasih, ini saran yang bermanfaat. Apa yang saya lakukan sejauh ini pada dasarnya adalah melanjutkan dengan memanggil SecurityContextHolder.getContext () (melalui beberapa metode pembungkus saya sendiri, jadi setidaknya itu hanya dipanggil dari satu kelas).
matt b
2
Meskipun hanya satu catatan - saya tidak berpikir ServletContextHolder memiliki konsep HttpSession atau cara mengetahui apakah itu beroperasi di lingkungan server web - ia menggunakan ThreadLocal kecuali jika Anda mengkonfigurasinya untuk menggunakan sesuatu yang lain (hanya dua mode bawaan lainnya yang dapat diwarisiThreadLocal dan Global)
matt b
Satu-satunya kelemahan untuk menggunakan kacang sesi / lingkup permintaan di Spring adalah bahwa mereka akan gagal dalam tes JUnit. Apa yang dapat Anda lakukan adalah menerapkan ruang lingkup khusus yang akan menggunakan sesi / permintaan jika tersedia dan kembali ke utas diperlukan. Dugaan saya adalah bahwa Spring Security melakukan sesuatu yang serupa ...
cliff.meyers
Tujuan saya adalah membuat api Rest tanpa sesi. Mungkin dengan token yang dapat disegarkan. Meskipun ini tidak menjawab pertanyaan saya, itu membantu. Terima kasih
Pomagranit
166

Lakukan saja dengan cara biasa dan kemudian sisipkan menggunakannya SecurityContextHolder.setContext()di kelas tes Anda, misalnya:

Pengendali:

Authentication a = SecurityContextHolder.getContext().getAuthentication();

Uji:

Authentication authentication = Mockito.mock(Authentication.class);
// Mockito.whens() for your authorization object
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getAuthentication()).thenReturn(authentication);
SecurityContextHolder.setContext(securityContext);
Leonardo Eloy
sumber
2
@ Leonardo di mana ini Authentication aharus ditambahkan di controller? Seperti yang saya bisa mengerti dalam setiap doa metode? Apakah saya boleh untuk "cara semi" hanya untuk menambahkannya, daripada menyuntikkan?
Oleg Kuts
Tapi ingat itu tidak akan bekerja dengan TestNG karena SecurityContextHolder memegang variabel thread lokal sehingga Anda berbagi variabel ini di antara tes ...
Łukasz Woźniczka
Lakukan di @BeforeEach(JUnit5) atau @Before(JUnit 4). Bagus dan sederhana.
WesternGun
30

Tanpa menjawab pertanyaan tentang cara membuat dan menyuntikkan objek Otentikasi, Spring Security 4.0 memberikan beberapa alternatif selamat datang ketika datang ke pengujian. The @WithMockUserpenjelasan memungkinkan pengembang untuk menentukan pengguna mock (dengan otoritas opsional, username, password dan peran) dengan cara yang rapi:

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
    String message = messageService.getMessage();
    ...
}

Ada juga opsi untuk digunakan @WithUserDetailsuntuk meniru yang UserDetailsdikembalikan dari UserDetailsService, misalnya

@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
    String message = messageService.getMessage();
    ...
}

Rincian lebih lanjut dapat ditemukan di @WithMockUser dan @WithUserDetails bab dalam dokumen referensi Spring Security (dari mana contoh di atas disalin)

matsev
sumber
29

Anda cukup benar untuk khawatir - panggilan metode statis sangat bermasalah untuk pengujian unit karena Anda tidak dapat dengan mudah mengejek dependensi Anda. Apa yang akan saya tunjukkan kepada Anda adalah bagaimana membiarkan wadah Spring IoC melakukan pekerjaan kotor untuk Anda, meninggalkan Anda dengan rapi, kode yang dapat diuji. SecurityContextHolder adalah kelas kerangka kerja dan meskipun mungkin ok untuk kode keamanan tingkat rendah Anda terikat padanya, Anda mungkin ingin mengekspos antarmuka yang lebih rapi ke komponen UI Anda (yaitu pengontrol).

cliff.meyers menyebutkan satu cara untuk mengatasinya - buat tipe "prinsipal" Anda sendiri dan suntikkan instance ke konsumen. Tag < aop: scoped-proxy /> Spring diperkenalkan pada 2.x dikombinasikan dengan definisi lingkup kacang permintaan, dan dukungan metode pabrik mungkin menjadi tiket ke kode yang paling mudah dibaca.

Ini bisa bekerja seperti berikut:

public class MyUserDetails implements UserDetails {
    // this is your custom UserDetails implementation to serve as a principal
    // implement the Spring methods and add your own methods as appropriate
}

public class MyUserHolder {
    public static MyUserDetails getUserDetails() {
        Authentication a = SecurityContextHolder.getContext().getAuthentication();
        if (a == null) {
            return null;
        } else {
            return (MyUserDetails) a.getPrincipal();
        }
    }
}

public class MyUserAwareController {        
    MyUserDetails currentUser;

    public void setCurrentUser(MyUserDetails currentUser) { 
        this.currentUser = currentUser;
    }

    // controller code
}

Sejauh ini tidak ada yang rumit, bukan? Bahkan Anda mungkin harus melakukan sebagian besar dari ini. Selanjutnya, dalam konteks kacang Anda, tentukan kacang lingkup permintaan untuk memegang kepala sekolah:

<bean id="userDetails" class="MyUserHolder" factory-method="getUserDetails" scope="request">
    <aop:scoped-proxy/>
</bean>

<bean id="controller" class="MyUserAwareController">
    <property name="currentUser" ref="userDetails"/>
    <!-- other props -->
</bean>

Berkat keajaiban aop: scoped-proxy tag, metode statis getUserDetails akan dipanggil setiap kali permintaan HTTP baru masuk dan referensi ke properti currentUser akan diselesaikan dengan benar. Sekarang pengujian unit menjadi sepele:

protected void setUp() {
    // existing init code

    MyUserDetails user = new MyUserDetails();
    // set up user as you wish
    controller.setCurrentUser(user);
}

Semoga ini membantu!

Pavel
sumber
9

Secara pribadi saya hanya akan menggunakan Powermock bersama dengan Mockito atau Easymock untuk mengejek SecurityContextHolder.getSecurityContext () yang statis dalam unit / tes integrasi misalnya

@RunWith(PowerMockRunner.class)
@PrepareForTest(SecurityContextHolder.class)
public class YourTestCase {

    @Mock SecurityContext mockSecurityContext;

    @Test
    public void testMethodThatCallsStaticMethod() {
        // Set mock behaviour/expectations on the mockSecurityContext
        when(mockSecurityContext.getAuthentication()).thenReturn(...)
        ...
        // Tell mockito to use Powermock to mock the SecurityContextHolder
        PowerMockito.mockStatic(SecurityContextHolder.class);

        // use Mockito to set up your expectation on SecurityContextHolder.getSecurityContext()
        Mockito.when(SecurityContextHolder.getSecurityContext()).thenReturn(mockSecurityContext);
        ...
    }
}

Harus diakui ada sedikit kode pelat ketel di sini yaitu mengejek objek Otentikasi, mengejek SecurityContext untuk mengembalikan Otentikasi dan akhirnya mengejek SecurityContextHolder untuk mendapatkan SecurityContext, namun sangat fleksibel dan memungkinkan Anda untuk menguji unit untuk skenario seperti objek Otentikasi null dll. tanpa harus mengubah kode (non tes) Anda


sumber
7

Menggunakan statis dalam hal ini adalah cara terbaik untuk menulis kode aman.

Ya, statika umumnya buruk - umumnya, tetapi dalam kasus ini, statis adalah yang Anda inginkan. Karena konteks keamanan mengaitkan Principal dengan utas yang saat ini berjalan, kode yang paling aman akan mengakses statis dari utas secara langsung. Menyembunyikan akses di belakang kelas pembungkus yang disuntikkan memberikan penyerang poin lebih banyak untuk diserang. Mereka tidak memerlukan akses ke kode (yang akan sulit diubah jika toples ditandatangani), mereka hanya perlu cara untuk mengesampingkan konfigurasi, yang dapat dilakukan saat runtime atau memasukkan beberapa XML ke classpath. Bahkan menggunakan injeksi anotasi akan dapat ditimpa dengan XML eksternal. XML semacam itu dapat menyuntikkan sistem yang sedang berjalan dengan prinsip jahat.

Michael Bushe
sumber
4

Saya mengajukan pertanyaan yang sama sendiri di sini , dan baru saja mengirim jawaban yang baru saya temukan. Jawaban singkatnya adalah: menyuntikkan SecurityContext, dan merujuk SecurityContextHolderhanya pada konfigurasi Spring Anda untuk mendapatkanSecurityContext

Scott Bale
sumber
3

Umum

Sementara itu (sejak versi 3.2, pada tahun 2013, terima kasih kepada SEC-2298 ) otentikasi dapat disuntikkan ke dalam metode MVC menggunakan penjelasan @AuthenticationPrincipal :

@Controller
class Controller {
  @RequestMapping("/somewhere")
  public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
  }
}

Tes

Dalam pengujian unit Anda, Anda dapat memanggil Metode ini secara langsung. Dalam tes integrasi menggunakan org.springframework.test.web.servlet.MockMvcAnda dapat menggunakan org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()untuk menyuntikkan pengguna seperti ini:

mockMvc.perform(get("/somewhere").with(user(myUserDetails)));

Namun ini hanya akan langsung mengisi SecurityContext. Jika Anda ingin memastikan bahwa pengguna dimuat dari sesi dalam pengujian Anda, Anda dapat menggunakan ini:

mockMvc.perform(get("/somewhere").with(sessionUser(myUserDetails)));
/* ... */
private static RequestPostProcessor sessionUser(final UserDetails userDetails) {
    return new RequestPostProcessor() {
        @Override
        public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) {
            final SecurityContext securityContext = new SecurityContextImpl();
            securityContext.setAuthentication(
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
            );
            request.getSession().setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext
            );
            return request;
        }
    };
}
yankee
sumber
2

Saya akan melihat kelas tes abstrak Spring dan benda-benda tiruan yang dibicarakan di sini . Mereka menyediakan cara yang kuat untuk pengkabelan otomatis objek yang dikelola Spring Anda membuat pengujian unit dan integrasi lebih mudah.

digitalsanctum
sumber
Meskipun kelas-kelas tes sangat membantu, saya tidak yakin apakah itu berlaku di sini. Tes saya tidak memiliki konsep ApplicationContext - mereka tidak memerlukannya. Yang saya butuhkan adalah memastikan bahwa SecurityContext diisi sebelum metode pengujian berjalan - hanya terasa kotor harus mengaturnya di ThreadLocal terlebih dahulu
matt b
1

Otentikasi adalah properti dari utas di lingkungan server dengan cara yang sama seperti itu adalah properti dari proses di OS. Memiliki contoh kacang untuk mengakses informasi otentikasi akan menjadi konfigurasi yang tidak nyaman dan overhead kabel tanpa manfaat.

Mengenai otentikasi tes ada beberapa cara bagaimana Anda dapat membuat hidup Anda lebih mudah. Favorit saya adalah membuat anotasi khusus @Authenticateddan menguji pendengar eksekusi, yang mengaturnya. Periksa DirtiesContextTestExecutionListenerinspirasi.

Pavel Horal
sumber
0

Setelah cukup banyak bekerja saya dapat mereproduksi perilaku yang diinginkan. Saya telah meniru login melalui MockMvc. Ini terlalu berat untuk sebagian besar tes unit tetapi membantu untuk tes integrasi.

Tentu saja saya bersedia melihat fitur-fitur baru di Spring Security 4.0 yang akan membuat pengujian kami lebih mudah.

package [myPackage]

import static org.junit.Assert.*;

import javax.inject.Inject;
import javax.servlet.http.HttpSession;

import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
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;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@ContextConfiguration(locations={[my config file locations]})
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public static class getUserConfigurationTester{

    private MockMvc mockMvc;

    @Autowired
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    private MockHttpServletRequest request;

    @Autowired
    private WebApplicationContext webappContext;

    @Before  
    public void init() {  
        mockMvc = MockMvcBuilders.webAppContextSetup(webappContext)
                    .addFilters(springSecurityFilterChain)
                    .build();
    }  


    @Test
    public void testTwoReads() throws Exception{                        

    HttpSession session  = mockMvc.perform(post("/j_spring_security_check")
                        .param("j_username", "admin_001")
                        .param("j_password", "secret007"))
                        .andDo(print())
                        .andExpect(status().isMovedTemporarily())
                        .andExpect(redirectedUrl("/index"))
                        .andReturn()
                        .getRequest()
                        .getSession();

    request.setSession(session);

    SecurityContext securityContext = (SecurityContext)   session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);

    SecurityContextHolder.setContext(securityContext);

        // Your test goes here. User is logged with 
}
borjab
sumber