Files
votianng/backend/src/test/java/de/stadtbote/votian/ApiSecurityTest.java
T

192 lines
13 KiB
Java

package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"})
@AutoConfigureMockMvc
class ApiSecurityTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@MockitoBean DatabaseService database;
record Csrf(MockHttpSession session, String token, String header) {}
@BeforeEach void configure() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden"));
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111")));
}
Csrf csrf(MockHttpSession session) throws Exception {
var request = get("/api/csrf");
if (session != null) request.session(session);
MvcResult response = mvc.perform(request).andExpect(status().isOk())
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store")))
.andReturn();
var body = json.readTree(response.getResponse().getContentAsString());
return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText());
}
MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception {
// Represents upstream htaccess authentication: VoTiAn must ignore this header.
return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token())
.with(httpBasic("proxy-fixture","proxy-password"))
.contentType("application/x-www-form-urlencoded").param("username",username).param("password",password))
.andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
}
MockHttpSession authenticated(String username, String password) throws Exception {
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
}
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true))
.andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false));
MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
assertThat(unauthorized.getRequest().getSession(false)).isNull();
Csrf csrf = csrf(null);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}"))
.andExpect(status().isUnauthorized());
}
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
Csrf anonymous = csrf(null);
String previousId = anonymous.session().getId();
MvcResult login = login(anonymous,"existing-user","existing-password",200);
var body = json.readTree(login.getResponse().getContentAsString());
assertThat(body.get("username").asText()).isEqualTo("existing-user");
assertThat(body.has("password")).isFalse();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session.getId()).isNotEqualTo(previousId);
SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
assertThat(context.getAuthentication().getCredentials()).isNull();
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
}
@Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception {
mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password")))
.andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception {
mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isUnauthorized());
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception {
Csrf anonymous = csrf(null);
MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false);
mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token()))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
Csrf authenticated = csrf(session);
mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token()))
.andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0));
assertThat(session.isInvalid()).isTrue();
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
Csrf afterLogout = csrf(null);
mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden());
login(afterLogout,"existing-user","existing-password",200);
}
@Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
}
@Test void csrfCannotBeReusedAcrossSessions() throws Exception {
Csrf first = csrf(null);
Csrf second = csrf(null);
mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
Csrf csrf = csrf(session);
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
.contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}"))
.andExpect(status().isNotImplemented());
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
var result = login(csrf(null),"offline","password",503);
assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE);
assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull();
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false));
}
@Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception {
Csrf wrong = csrf(null);
login(wrong,"unknown","incorrect",401);
mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111")));
login(csrf(null),"invalid","password",401);
}
@Test void rightsAreEnforcedBeyondAuthentication() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000")));
MockHttpSession session = authenticated("restricted","password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false));
mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden());
}
@Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen."));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false))
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
}
}