可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
My company has been evaluating Spring MVC to determine if we should use it in one of our next projects. So far I love what I've seen, and right now I'm taking a look at the Spring Security module to determine if it's something we can/should use.
Our security requirements are pretty basic; a user just needs to be able to provide a username and password to be able to access certain parts of the site (such as to get info about their account); and there are a handful of pages on the site (FAQs, Support, etc) where an anonymous user should be given access.
In the prototype I've been creating, I have been storing a "LoginCredentials" object (which just contains username and password) in Session for an authenticated user; some of the controllers check to see if this object is in session to get a reference to the logged-in username, for example. I'm looking to replace this home-grown logic with Spring Security instead, which would have the nice benefit of removing any sort of "how do we track logged in users?" and "how do we authenticate users?" from my controller/business code.
It seems like Spring Security provides a (per-thread) "context" object to be able to access the username/principal info from anywhere in your app...
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
... which seems very un-Spring like as this object is a (global) singleton, in a way.
My question is this: if this is the standard way to access information about the authenticated user in Spring Security, what is the accepted way to inject an Authentication object into the SecurityContext so that it is available for my unit tests when the unit tests require an authenticated user?
Do I need to wire this up in the initialization method of each test case?
protected void setUp() throws Exception {
...
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
...
}
This seems overly verbose. Is there an easier way?
The SecurityContextHolder
object itself seems very un-Spring-like...
回答1:
The problem is that Spring Security does not make the Authentication object available as a bean in the container, so there is no way to easily inject or autowire it out of the box.
Before we started to use Spring Security, we would create a session-scoped bean in the container to store the Principal, inject this into an "AuthenticationService" (singleton) and then inject this bean into other services that needed knowledge of the current Principal.
If you are implementing your own authentication service, you could basically do the same thing: create a session-scoped bean with a "principal" property, inject this into your authentication service, have the auth service set the property on successful auth, and then make the auth service available to other beans as you need it.
I wouldn't feel too bad about using SecurityContextHolder. though. I know that it's a static / Singleton and that Spring discourages using such things but their implementation takes care to behave appropriately depending on the environment: session-scoped in a Servlet container, thread-scoped in a JUnit test, etc. The real limiting factor of a Singleton is when it provides an implementation that is inflexible to different environments.
回答2:
Just do it the usual way and then insert it using SecurityContextHolder.setContext()
in your test class, for example:
Controller:
Authentication a = SecurityContextHolder.getContext().getAuthentication();
Test:
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);
回答3:
You are quite right to be concerned - static method calls are particularly problematic for unit testing as you cannot easily mock your dependencies. What I am going to show you is how to let the Spring IoC container do the dirty work for you, leaving you with neat, testable code. SecurityContextHolder is a framework class and while it may be ok for your low-level security code to be tied to it, you probably want to expose a neater interface to your UI components (i.e. controllers).
cliff.meyers mentioned one way around it - create your own "principal" type and inject an instance into consumers. The Spring <aop:scoped-proxy/> tag introduced in 2.x combined with a request scope bean definition, and the factory-method support may be the ticket to the most readable code.
It could work like following:
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
}
Nothing complicated so far, right? In fact you probably had to do most of this already. Next, in your bean context define a request-scoped bean to hold the principal:
<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>
Thanks to the magic of the aop:scoped-proxy tag, the static method getUserDetails will be called every time a new HTTP request comes in and any references to the currentUser property will be resolved correctly. Now unit testing becomes trivial:
protected void setUp() {
// existing init code
MyUserDetails user = new MyUserDetails();
// set up user as you wish
controller.setCurrentUser(user);
}
Hope this helps!
回答4:
Without answering the question about how to create and inject Authentication objects, Spring Security 4.0 provides some welcome alternatives when it comes to testing. The @WithMockUser
annotation enables the developer to specify a mock user (with optional authorities, username, password and roles) in a neat way:
@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}
There is also the option to use @WithUserDetails
to emulate a UserDetails
returned from the UserDetailsService
, e.g.
@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
String message = messageService.getMessage();
...
}
More details can be found in the @WithMockUser and the @WithUserDetails chapters in the Spring Security reference docs (from which the above examples where copied)
回答5:
Personally I would just use Powermock along with Mockito or Easymock to mock the static SecurityContextHolder.getSecurityContext() in your unit/integration test e.g.
@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);
...
}
}
Admittedly there is quite a bit of boiler plate code here i.e. mock an Authentication object, mock a SecurityContext to return the Authentication and finally mock the SecurityContextHolder to get the SecurityContext, however its very flexible and allows you to unit test for scenarios like null Authentication objects etc. without having to change your (non test) code
回答6:
Using a static in this case is the best way to write secure code.
Yes, statics are generally bad - generally, but in this case, the static is what you want. Since the security context associates a Principal with the currently running thread, the most secure code would access the static from the thread as directly as possible. Hiding the access behind a wrapper class that is injected provides an attacker with more points to attack. They wouldn't need access to the code (which they would have a hard time changing if the jar was signed), they just need a way to override the configuration, which can be done at runtime or slipping some XML onto the classpath. Even using annotation injection would be overridable with external XML. Such XML could inject the running system with a rogue principal.
回答7:
I asked the same question myself over here, and just posted an answer that I recently found. Short answer is: inject a SecurityContext
, and refer to SecurityContextHolder
only in your Spring config to obtain the SecurityContext
回答8:
I would take a look at Spring's abstract test classes and mock objects which are talked about here. They provide a powerful way of auto-wiring your Spring managed objects making unit and integration testing easier.
回答9:
General
In the meantime (since version 3.2, in the year 2013, thanks to SEC-2298) the authentication can be injected into MVC methods using the annotation @AuthenticationPrincipal:
@Controller
class Controller {
@RequestMapping("/somewhere")
public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
}
}
Tests
In your unit test you can obviously call this Method directly. In integration tests using org.springframework.test.web.servlet.MockMvc
you can use org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()
to inject the user like this:
mockMvc.perform(get("/somewhere").with(user(myUserDetails)));
This will however just directly fill the SecurityContext. If you want to make sure that the user is loaded from a session in your test, you can use this:
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;
}
};
}
回答10:
Authentication is a property of a thread in server environment in the same way as it is a property of a process in OS. Having a bean instance for accessing authentication information would be inconvenient configuration and wiring overhead without any benefit.
Regarding test authentication there are several ways how you can make your life easier. My favourite is to make a custom annotation @Authenticated
and test execution listener, which manages it. Check DirtiesContextTestExecutionListener
for inspiration.
回答11:
After quite a lot of work I was able to reproduce the desired behavior. I had emulated the login through MockMvc. It is too heavy for most unit tests but helpful for integration tests.
Of course I am willing to see those new features in Spring Security 4.0 that will make our testing easier.
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
}