Issue
When accessing a specific URL with a different IP address than allowed, spring security will auto forward the request to the login page instead of denying the request.
Here's my Spring Security Expression
http.authorizeRequests()
.antMatchers("/" + WELCOME_PAGE,
"/" + FOOD_SELECTION_PAGE + "/**",
"/" + CHECKOUT_URL,
"/" + VERIFY_BADGE_URL,
"/" + VERIFY_NAME_URL).hasIpAddress("x.x.x.x")
.antMatchers("/" + ADMIN_PAGE,
"/" + NEW_FOOD_PAGE,
"/" + HIDE_FOOD_URL,
"/" + SHOW_FOOD_URL,
"/" + DELETE_FOOD_URL,
"/" + EDIT_FOOD_URL,
"/" + REFRESH_EMPS_URL).hasAnyAuthority("ROLES_USER")
.antMatchers("/**").permitAll()
.and().formLogin().loginPage("/" + LOGIN_PAGE).usernameParameter("username").passwordParameter("pin").defaultSuccessUrl("/" + ADMIN_PAGE).permitAll().failureUrl("/" + LOGIN_PAGE + "?badlogin");
If I do login, it will take me back to the original request URL and it will throw a 403 denial. I don't want it to forward to the login page, I just want it to deny immediately.
I even tried a denyAll() instead of hasIpAddress() and it did the same thing.
I did a scan through the spring docs at https://docs.spring.io/spring-security/site/docs/current/reference/html5/ various google searches and I couldn't find anything that talks about this specifically.
I would like the users to still be able to go to any page in that second antMatchers and have it auto forward to the login page if they're not authenticated yet.
Solution
So I wrote a comment on this which said that this was hard to google, which is true, and that what you want is a custom AuthenticationFailureHandler
, which is false.
In actual fact, what you want is a custom AuthenticationEntryPoint
. The class that's responsible for the behaviour you mislike is ExceptionTranslatorFilter
. From its javadoc:
If an AuthenticationException is detected, the filter will launch the
authenticationEntryPoint
. [...] If anAccessDeniedException
is detected, the filter will determine whether or not the user is an anonymous user. If they are an anonymous user, theauthenticationEntryPoint
will be launched. If they are not an anonymous user, the filter will delegate to theAccessDeniedHandler
.
It turns out that an AccessDeniedException
is thrown both when the user does not have a required role and when the request does not have one of the permitted remote addresses. So the ExceptionTranslatorFilter
calls its authenticationEntryPoint
in both cases. That authenticationEntryPoint
is set when you call HttpSecurity.formLogin()
; specifically, it's set to a LoginUrlAuthenticationEntryPoint
.
This particular bit of Spring Security default configuration is what you want to override -- you want the ExceptionTranslationFilter
to use an AuthenticationEntryPoint
that does different things based on what request it's handling. That's what the Spring Security-provided DelegatingAuthenticationEntryPoint
is for.
The way to customize the authentication entry point is to call exceptionHandling()
on your HttpSecurity
. Thus, we arrive at the configuration you see in the following Spring Boot test:
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// many non-static imports
@SpringBootTest
class SecurityTest {
@Autowired WebApplicationContext wac;
MockMvc mvc;
@RestController
static class BigController {
@GetMapping("/needs-ip")
public String needsIp() {
return "Needs IP";
}
@GetMapping("/needs-no-ip")
public String needsNoIp() {
return "Needs no IP";
}
}
@TestConfiguration
@EnableWebSecurity
static class ConfigurationForTest extends WebSecurityConfigurerAdapter {
@Bean BigController controller() { return new BigController(); }
@Override
public void configure(HttpSecurity http) throws Exception {
LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> map = new LinkedHashMap<>();
// Http403ForbiddenEntryPoint basically just says "don't bother with authentication, return 403 instead"
map.put(new AntPathRequestMatcher("/needs-ip"), new Http403ForbiddenEntryPoint());
// normally, Spring Boot adds this authentication entry point on its own, but we've taken
// over the configuration so we do it ourselves
map.put(new AntPathRequestMatcher("/needs-no-ip"), new LoginUrlAuthenticationEntryPoint("/login"));
http.authorizeRequests()
.antMatchers("/needs-ip").hasIpAddress("192.168.12.13")
.antMatchers("/needs-no-ip").hasRole("USER")
.anyRequest().permitAll() // more readable than "antMatchers("/**")"
.and().formLogin() // with the default url, which is "/login"
// the line that follows is the interesting one
.and().exceptionHandling().authenticationEntryPoint(new DelegatingAuthenticationEntryPoint(map));
;
}
}
@BeforeEach
public void configureMockMvc() {
this.mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build();
}
@Test
void needsIpCorrectIp() throws Exception {
mvc.perform(get("/needs-ip").with(req -> {req.setRemoteAddr("192.168.12.13"); return req;}))
.andExpect(status().isOk())
.andExpect(content().string("Needs IP"));
}
@Test
void needsIpWrongIpAnonymousUser() throws Exception {
mvc.perform(get("/needs-ip"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "USER")
void needsIpWrongIpLoggedInUser() throws Exception {
mvc.perform(get("/needs-ip"))
.andExpect(status().isForbidden());
}
@Test
void needsNoIpAnonymousUser() throws Exception {
mvc.perform(get("/needs-no-ip"))
.andExpect(status().isFound())
// apparently, the default hostname in Spring MockMvc tests is localhost
// you can change it, but why bother?
.andExpect(header().string("Location", "http://localhost/login"));
}
@Test
@WithMockUser(roles = "USER")
void needsNoIpAuthorizedUser() throws Exception {
mvc.perform(get("/needs-no-ip"))
.andExpect(status().isOk())
.andExpect(content().string("Needs no IP"));
}
@Test
@WithMockUser(roles = "NOTUSER")
void needsNoIpUnauthorizedUser() throws Exception {
mvc.perform(get("/needs-no-ip"))
.andExpect(status().isForbidden());
}
}
If you download a project from Spring Initializr, add spring-security-test, spring-starter-web and spring-starter-security as dependencies, and run mvn verify
, the tests should pass. I've included a @RestController
so it's obvious the happy path also works as expected. Of course, this configuration is less complex than yours, and I don't tend to write maintainable code for Stack Overflow answers. (There should be more constants and less strings in there). But you can probably modify the configuration so it works for you.
Incidentally, I'm not sure that your configuration is complex enough -- personally, I would want even requests from whitelisted IP addresses to fail if no authentication/authorization is present -- but I don't know your security requirements, so I can't really judge.
Answered By - neofelis
Answer Checked By - Katrina (JavaFixing Volunteer)