接入 AD 密码验证与直接所属组,保留待 MFA 边界
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package top.ddupan.iam.login.ad;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
||||
import com.unboundid.ldap.listener.InMemoryListenerConfig;
|
||||
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSimpleBindRequest;
|
||||
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
|
||||
import com.unboundid.ldap.sdk.Entry;
|
||||
import com.unboundid.ldap.sdk.LDAPException;
|
||||
import com.unboundid.ldap.sdk.ResultCode;
|
||||
import com.unboundid.ldap.sdk.SimpleBindRequest;
|
||||
import java.net.InetAddress;
|
||||
import java.security.KeyStore;
|
||||
import java.time.Instant;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.context.annotation.ImportRuntimeHints;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.CredentialsExpiredException;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/** Real LDAPS sockets and Spring's AD provider; AD bind/subcode semantics are simulated. */
|
||||
@SpringBootTest(properties = {"iam.ad.enabled=true", "iam.ad.domain=example.test", "iam.ad.base-dn=dc=example,dc=test"})
|
||||
@AutoConfigureMockMvc
|
||||
@ImportRuntimeHints(AdIntegrationTests.FixtureHints.class)
|
||||
class AdIntegrationTests {
|
||||
static class FixtureHints implements RuntimeHintsRegistrar {
|
||||
@Override
|
||||
public void registerHints(RuntimeHints hints, ClassLoader loader) {
|
||||
hints.resources().registerPattern("ldap/fixture.p12");
|
||||
}
|
||||
}
|
||||
|
||||
static final String BASE = "dc=example,dc=test";
|
||||
static final String USER_DN = "cn=Alice," + BASE;
|
||||
static final byte[] GUID = java.util.HexFormat.of().parseHex("33221100554477668899aabbccddeeff");
|
||||
static class Fixture {
|
||||
static final SSLContext ORIGINAL;
|
||||
static final InMemoryDirectoryServer LDAP;
|
||||
static {
|
||||
try {
|
||||
ORIGINAL = SSLContext.getDefault();
|
||||
var store = KeyStore.getInstance("PKCS12");
|
||||
try (var stream = AdIntegrationTests.class.getResourceAsStream("/ldap/fixture.p12")) {
|
||||
store.load(stream, "fixture-only".toCharArray());
|
||||
}
|
||||
var keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
keys.init(store, "fixture-only".toCharArray());
|
||||
var trust = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trust.init(store);
|
||||
var ssl = SSLContext.getInstance("TLS");
|
||||
ssl.init(keys.getKeyManagers(), trust.getTrustManagers(), null);
|
||||
SSLContext.setDefault(ssl);
|
||||
var config = new InMemoryDirectoryServerConfig(BASE);
|
||||
config.setSchema(null);
|
||||
config.setListenerConfigs(InMemoryListenerConfig.createLDAPSConfig("ldaps",
|
||||
InetAddress.getByName("127.0.0.1"), 0, ssl.getServerSocketFactory(), ssl.getSocketFactory()));
|
||||
config.addInMemoryOperationInterceptor(new InMemoryOperationInterceptor() {
|
||||
@Override
|
||||
public void processSimpleBindRequest(InMemoryInterceptedSimpleBindRequest request) throws LDAPException {
|
||||
String name = request.getRequest().getBindDN();
|
||||
String subcode = switch (name) {
|
||||
case "[email protected]" -> "533";
|
||||
case "[email protected]" -> "775";
|
||||
case "[email protected]" -> "532";
|
||||
default -> null;
|
||||
};
|
||||
if (subcode != null) throw new LDAPException(ResultCode.INVALID_CREDENTIALS,
|
||||
"80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data " + subcode + ", v1db1");
|
||||
if (name.equalsIgnoreCase("[email protected]")) {
|
||||
request.setRequest(new SimpleBindRequest(USER_DN, request.getRequest().getPassword().getValue()));
|
||||
} else if (!name.equals(USER_DN)) {
|
||||
throw new LDAPException(ResultCode.INVALID_CREDENTIALS, "Invalid credentials");
|
||||
}
|
||||
}
|
||||
});
|
||||
LDAP = new InMemoryDirectoryServer(config);
|
||||
LDAP.startListening();
|
||||
LDAP.add(new Entry(BASE, new com.unboundid.ldap.sdk.Attribute("objectClass", "domain"),
|
||||
new com.unboundid.ldap.sdk.Attribute("dc", "example")));
|
||||
LDAP.add(new Entry(USER_DN,
|
||||
new com.unboundid.ldap.sdk.Attribute("objectClass", "user"),
|
||||
new com.unboundid.ldap.sdk.Attribute("cn", "Alice"),
|
||||
new com.unboundid.ldap.sdk.Attribute("sAMAccountName", "alice"),
|
||||
new com.unboundid.ldap.sdk.Attribute("userPrincipalName", "[email protected]"),
|
||||
new com.unboundid.ldap.sdk.Attribute("userPassword", "fixture-password"),
|
||||
new com.unboundid.ldap.sdk.Attribute("displayName", "Alice </script><script>attack()</script>"),
|
||||
new com.unboundid.ldap.sdk.Attribute("mail", "[email protected]"),
|
||||
new com.unboundid.ldap.sdk.Attribute("objectGUID", GUID),
|
||||
new com.unboundid.ldap.sdk.Attribute("memberOf", "CN=gitea-admins," + BASE, "CN=MixedCase," + BASE)));
|
||||
} catch (Exception ex) { throw new ExceptionInInitializerError(ex); }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void directory(DynamicPropertyRegistry registry) {
|
||||
registry.add("iam.ad.url", () -> "ldaps://localhost:" + Fixture.LDAP.getListenPort());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void close() { Fixture.LDAP.shutDown(true); SSLContext.setDefault(Fixture.ORIGINAL); }
|
||||
|
||||
@Autowired AdPasswordVerifier verifier;
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void passwordReadsGuidAndExactGroupsOverTls() {
|
||||
var identity = verifier.verify("alice", "fixture-password");
|
||||
assertThat(identity.objectGuid()).isEqualTo("00112233-4455-6677-8899-aabbccddeeff");
|
||||
assertThat(identity.groups()).containsExactly("MixedCase", "gitea-admins");
|
||||
assertThat(identity.groupDns()).containsExactly("CN=MixedCase," + BASE, "CN=gitea-admins," + BASE);
|
||||
assertThat(verifier.verify("[email protected]", "fixture-password")).isEqualTo(identity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPasswordsUnknownUsersAndAdAccountStates() {
|
||||
assertThatThrownBy(() -> verifier.verify("alice", "wrong")).isInstanceOf(BadCredentialsException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("alice", "")).isInstanceOf(BadCredentialsException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("unknown", "fixture-password")).isInstanceOf(BadCredentialsException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("[email protected]", "fixture-password")).isInstanceOf(BadCredentialsException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("disabled", "fixture-password")).isInstanceOf(DisabledException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("locked", "fixture-password")).isInstanceOf(LockedException.class);
|
||||
assertThatThrownBy(() -> verifier.verify("expired", "fixture-password")).isInstanceOf(CredentialsExpiredException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongTlsHostnameAndPlainLdapConfiguration() {
|
||||
var wrongName = new AdPasswordVerifier(new AdProperties(true,
|
||||
"ldaps://127.0.0.1:" + Fixture.LDAP.getListenPort(), "example.test", BASE));
|
||||
assertThatThrownBy(() -> wrongName.verify("alice", "fixture-password"))
|
||||
.hasStackTraceContaining("No subject alternative names matching IP address");
|
||||
assertThatThrownBy(() -> new AdPasswordVerifier(new AdProperties(true,
|
||||
"ldap://localhost:389", "example.test", BASE))).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void browserRequiresHttpsCsrfAndOrderedSteps() throws Exception {
|
||||
mvc.perform(get("/signin")).andExpect(status().isUpgradeRequired());
|
||||
mvc.perform(get("/signin/mfa").secure(true)).andExpect(redirectedUrl("/signin"));
|
||||
mvc.perform(post("/signin/password").secure(true).param("username", "alice")
|
||||
.param("password", "fixture-password")).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulPasswordRotatesSessionAndStopsBeforeMfa() throws Exception {
|
||||
var session = new MockHttpSession();
|
||||
mvc.perform(get("/signin").secure(true).session(session)).andExpect(status().isOk());
|
||||
String oldId = session.getId();
|
||||
mvc.perform(post("/signin/password").secure(true).session(session).with(csrf())
|
||||
.param("username", "alice").param("password", "fixture-password"))
|
||||
.andExpect(redirectedUrl("/signin/mfa"));
|
||||
assertThat(session.getId()).isNotEqualTo(oldId);
|
||||
var html = mvc.perform(get("/signin/mfa").secure(true).session(session))
|
||||
.andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertThat(html).contains("mfa-pending", "gitea-admins", "\\u003c/script\\u003e")
|
||||
.doesNotContain("fixture-password", "</script><script>attack()");
|
||||
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
|
||||
mvc.perform(get("/").secure(true).session(session).accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
var state = (AdLoginController.State) session.getAttribute(AdLoginController.STATE);
|
||||
state.expires = Instant.EPOCH;
|
||||
mvc.perform(get("/signin/mfa").secure(true).session(session)).andExpect(redirectedUrl("/signin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPasswordDoesNotRetainIdentityAndRestartInvalidatesSession() throws Exception {
|
||||
var session = new MockHttpSession();
|
||||
mvc.perform(get("/signin").secure(true).session(session));
|
||||
mvc.perform(post("/signin/password").secure(true).session(session).with(csrf())
|
||||
.param("username", "alice").param("password", "wrong"))
|
||||
.andExpect(redirectedUrl("/signin"));
|
||||
var state = (AdLoginController.State) session.getAttribute(AdLoginController.STATE);
|
||||
assertThat(state.identity).isNull();
|
||||
assertThat(state.error).isNotBlank().doesNotContain("LDAP", "wrong");
|
||||
mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf()))
|
||||
.andExpect(redirectedUrl("/signin"));
|
||||
assertThat(session.isInvalid()).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user