Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen
This commit is contained in:
commit
1a30233269
63 files changed
+12138
No files matched your search
@@ -0,0 +1,191 @@
|
||||
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")));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user