接入 AD 密码验证与直接所属组,保留待 MFA 边界

This commit is contained in:
2026-09-25 21:06:39 +00:00
parent 2840f58397
commit d14151928d
18 changed files with 699 additions and 33 deletions
@@ -0,0 +1,128 @@
package top.ddupan.iam.login.ad;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.time.Instant;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import top.ddupan.iam.login.preview.PageRenderer;
/** Human first-factor PoC. No SecurityContext, MFA acceptance, or Hydra calls. */
@RestController
public class AdLoginController {
static final String STATE = AdLoginController.class.getName() + ".state";
private final AdPasswordVerifier verifier;
private final PageRenderer renderer;
public AdLoginController(AdPasswordVerifier verifier, PageRenderer renderer) {
this.verifier = verifier;
this.renderer = renderer;
}
@GetMapping(value = "/signin", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> page(HttpServletRequest request, CsrfToken csrf) {
requireAvailable(request);
var session = request.getSession();
synchronized (session) {
var state = state(session);
if (state.identity != null) return redirect("/signin/mfa");
return renderer.render(Map.of("step", "password", "name", state.username,
"error", state.error, "action", "/signin/password", "csrf", csrf(csrf)));
}
}
@PostMapping("/signin/password")
ResponseEntity<String> password(HttpServletRequest request,
@RequestParam(defaultValue = "") String username,
@RequestParam(defaultValue = "") String password) {
requireAvailable(request);
var session = request.getSession(false);
if (session == null) throw new ResponseStatusException(HttpStatus.CONFLICT);
synchronized (session) {
var state = (State) session.getAttribute(STATE);
if (state == null || state.identity != null || state.expires.isBefore(Instant.now())) {
return redirect("/signin");
}
// Prevent double submissions in this transaction; perimeter rate limits belong at ingress.
if (state.retryAfter.isAfter(Instant.now())) throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS);
state.retryAfter = Instant.now().plusSeconds(2);
state.username = username.length() <= 256 ? username : "";
try {
var identity = verifier.verify(username, password);
request.changeSessionId();
state.identity = identity;
state.error = "";
state.expires = Instant.now().plusSeconds(600);
return redirect("/signin/mfa");
} catch (AuthenticationException | org.springframework.dao.DataAccessException ex) {
// Neither directory exception details nor passwords enter HTML/session/logs.
state.error = "无法验证账号,请检查凭据与账号状态,或稍后重试。";
return redirect("/signin");
}
}
}
@GetMapping(value = "/signin/mfa", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> pending(HttpServletRequest request, CsrfToken csrf) {
requireAvailable(request);
var session = request.getSession(false);
if (session == null) return redirect("/signin");
synchronized (session) {
var state = state(session);
if (state.identity == null) return redirect("/signin");
var identity = state.identity;
return renderer.render(Map.of("step", "mfa-pending", "name", identity.displayName(),
"error", "", "action", "/signin/restart", "csrf", csrf(csrf),
"identity", Map.of("username", identity.username(), "objectGuid", identity.objectGuid(),
"email", identity.email(), "groups", identity.groups(), "groupDns", identity.groupDns())));
}
}
@PostMapping("/signin/restart")
ResponseEntity<String> restart(HttpServletRequest request) {
requireAvailable(request);
var session = request.getSession(false);
if (session != null) session.invalidate();
return redirect("/signin");
}
private void requireAvailable(HttpServletRequest request) {
if (!verifier.enabled()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (!request.isSecure()) throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED, "HTTPS required");
}
private static Map<String, String> csrf(CsrfToken token) {
return Map.of("name", token.getParameterName(), "value", token.getToken());
}
private static State state(HttpSession session) {
var state = (State) session.getAttribute(STATE);
if (state == null || state.expires.isBefore(Instant.now())) {
state = new State();
session.setAttribute(STATE, state);
}
return state;
}
private static ResponseEntity<String> redirect(String location) {
return ResponseEntity.status(HttpStatus.SEE_OTHER).header("Location", location)
.header("Cache-Control", "no-store").build();
}
static final class State {
String username = "";
String error = "";
DirectoryIdentity identity;
Instant expires = Instant.now().plusSeconds(600);
Instant retryAfter = Instant.EPOCH;
}
}
@@ -0,0 +1,151 @@
package top.ddupan.iam.login.ad;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import javax.naming.NamingException;
import javax.naming.ldap.LdapName;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
import org.springframework.security.ldap.userdetails.UserDetailsContextMapper;
import org.springframework.stereotype.Service;
/** Invoke explicitly as a first factor; never register this as a web AuthenticationProvider. */
@Service
@EnableConfigurationProperties(AdProperties.class)
public class AdPasswordVerifier {
private final ActiveDirectoryLdapAuthenticationProvider provider;
private final AdProperties properties;
public AdPasswordVerifier(AdProperties properties) {
this.properties = properties;
if (!properties.enabled()) {
provider = null;
return;
}
URI uri = URI.create(properties.url());
if (!"ldaps".equals(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null
|| uri.getQuery() != null || uri.getFragment() != null
|| properties.domain() == null || properties.domain().isBlank()
|| properties.baseDn() == null || properties.baseDn().isBlank()) {
throw new IllegalArgumentException("AD requires an LDAPS URL, domain and base DN");
}
provider = new ActiveDirectoryLdapAuthenticationProvider(
properties.domain(), properties.url(), properties.baseDn());
provider.setConvertSubErrorCodesToExceptions(true);
provider.setUseAuthenticationRequestCredentials(false);
provider.setSearchFilter("(&(objectClass=user)(!(objectClass=computer))(userPrincipalName={0}))");
provider.setContextEnvironmentProperties(Map.of(
"com.sun.jndi.ldap.connect.timeout", "3000",
"com.sun.jndi.ldap.read.timeout", "5000",
"java.naming.ldap.attributes.binary", "objectGUID",
"java.naming.referral", "throw"));
// Directory groups are mapped independently; do not confuse FACTOR_PASSWORD with a group.
provider.setAuthoritiesPopulator((entry, username) -> List.of());
provider.setUserDetailsContextMapper(new IdentityMapper());
}
public boolean enabled() { return properties.enabled(); }
public DirectoryIdentity verify(String username, String password) {
if (provider == null) throw new IllegalStateException("AD login is disabled");
if (username == null || username.isBlank() || username.length() > 256
|| username.contains("\\") || !username.equals(username.strip())
|| (username.contains("@") && !username.toLowerCase(java.util.Locale.ROOT)
.endsWith("@" + properties.domain().toLowerCase(java.util.Locale.ROOT)))
|| password == null || password.isEmpty() || password.length() > 1024) {
throw new BadCredentialsException("Invalid credentials");
}
var token = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
var result = provider.authenticate(token);
try {
return ((IdentityUser) result.getPrincipal()).identity;
} finally {
if (result instanceof org.springframework.security.core.CredentialsContainer credentials) {
credentials.eraseCredentials();
}
}
} finally {
token.eraseCredentials();
}
}
static final class IdentityUser extends User {
final DirectoryIdentity identity;
IdentityUser(DirectoryIdentity identity) {
super(identity.username(), "", List.of());
this.identity = identity;
}
}
static final class IdentityMapper implements UserDetailsContextMapper {
@Override
public UserDetails mapUserFromContext(DirContextOperations entry, String username,
Collection<? extends GrantedAuthority> authorities) {
try {
// Refuse an incomplete ranged result instead of silently dropping groups.
var ids = entry.getAttributes().getIDs();
try {
while (ids.hasMore()) {
if (ids.next().toLowerCase(java.util.Locale.ROOT).startsWith("memberof;")) {
throw new IllegalArgumentException("Ranged membership is not supported yet");
}
}
} finally { ids.close(); }
String account = required(entry, "sAMAccountName");
String display = entry.getStringAttribute("displayName");
String email = entry.getStringAttribute("mail");
String[] membership = entry.getStringAttributes("memberOf");
List<String> dns = membership == null ? List.of() : Arrays.stream(membership).sorted().toList();
var groups = new java.util.TreeSet<String>();
for (String dn : dns) {
var name = new LdapName(dn);
var rdn = name.getRdn(name.size() - 1);
if (!rdn.getType().equalsIgnoreCase("CN")) throw new IllegalArgumentException("Group has no CN");
if (!groups.add(rdn.getValue().toString())) throw new IllegalArgumentException("Ambiguous group CN");
}
return new IdentityUser(new DirectoryIdentity(
guid((byte[]) entry.getObjectAttribute("objectGUID")), account,
display == null ? account : display, email == null ? "" : email,
List.copyOf(groups), dns));
} catch (NamingException | IllegalArgumentException | ClassCastException ex) {
throw new InternalAuthenticationServiceException("Directory identity cannot be mapped", ex);
}
}
@Override
public void mapUserToContext(UserDetails user, DirContextAdapter context) {
throw new UnsupportedOperationException("Read-only directory integration");
}
private static String required(DirContextOperations entry, String attribute) {
String value = entry.getStringAttribute(attribute);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing directory attribute");
return value;
}
}
static String guid(byte[] bytes) {
if (bytes == null || bytes.length != 16) throw new IllegalArgumentException("Invalid objectGUID");
var little = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
long most = Integer.toUnsignedLong(little.getInt()) << 32
| (long) Short.toUnsignedInt(little.getShort()) << 16
| Short.toUnsignedInt(little.getShort());
long least = ByteBuffer.wrap(bytes, 8, 8).getLong();
return new UUID(most, least).toString();
}
}
@@ -0,0 +1,6 @@
package top.ddupan.iam.login.ad;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("iam.ad")
public record AdProperties(boolean enabled, String url, String domain, String baseDn) {}
@@ -0,0 +1,12 @@
package top.ddupan.iam.login.ad;
import java.util.List;
/** Directory key only: deliberately not a Hydra subject or a completed authentication. */
public record DirectoryIdentity(String objectGuid, String username, String displayName,
String email, List<String> groups, List<String> groupDns) {
public DirectoryIdentity {
groups = List.copyOf(groups);
groupDns = List.copyOf(groupDns);
}
}
@@ -0,0 +1,32 @@
package top.ddupan.iam.login.preview;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
/** Shared HTML shell; the page context is data, never executable JavaScript. */
@Component
public class PageRenderer {
private static final String SLOT = "__IAM_PAGE_CONTEXT__";
private final String shell;
private final JsonMapper json = JsonMapper.builder().build();
public PageRenderer() throws IOException {
shell = new ClassPathResource("ui/index.html").getContentAsString(StandardCharsets.UTF_8);
if (shell.indexOf(SLOT) < 0 || shell.indexOf(SLOT) != shell.lastIndexOf(SLOT)) {
throw new IllegalStateException("Expected exactly one UI context slot");
}
}
public ResponseEntity<String> render(Object context) {
String safe = json.writeValueAsString(context).replace("<", "\\u003c")
.replace(">", "\\u003e").replace("&", "\\u0026")
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029");
return ResponseEntity.ok().header("Cache-Control", "no-store")
.contentType(MediaType.TEXT_HTML).body(shell.replace(SLOT, safe));
}
}
@@ -19,7 +19,7 @@ class PreviewConfiguration implements WebMvcConfigurer {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/preview", "/preview/**", "/assets/**", "/actuator/health/**").permitAll()
.requestMatchers("/error", "/signin", "/signin/**", "/preview", "/preview/**", "/assets/**", "/actuator/health/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
@@ -2,11 +2,8 @@ package top.ddupan.iam.login.preview;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -16,23 +13,17 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.json.JsonMapper;
/** An isolated UI experiment. It never creates an authenticated SecurityContext. */
@RestController
class PreviewController {
private static final String STATE = PreviewController.class.getName() + ".state";
private static final String SLOT = "__IAM_PAGE_CONTEXT__";
private final boolean enabled;
private final String shell;
private final JsonMapper json = JsonMapper.builder().build();
private final PageRenderer renderer;
PreviewController(@Value("${iam.ui-preview.enabled:false}") boolean enabled) throws IOException {
PreviewController(@Value("${iam.ui-preview.enabled:false}") boolean enabled, PageRenderer renderer) {
this.enabled = enabled;
this.shell = new ClassPathResource("ui/index.html").getContentAsString(StandardCharsets.UTF_8);
if (shell.indexOf(SLOT) < 0 || shell.indexOf(SLOT) != shell.lastIndexOf(SLOT)) {
throw new IllegalStateException("Expected exactly one UI context slot");
}
this.renderer = renderer;
}
@GetMapping(value = {"/preview", "/preview/verify", "/preview/complete"}, produces = MediaType.TEXT_HTML_VALUE)
@@ -54,8 +45,7 @@ class PreviewController {
case "verification" -> "/preview/verify";
default -> "/preview/restart";
}, "csrf", Map.of("name", csrf.getParameterName(), "value", csrf.getToken()));
return ResponseEntity.ok().header("Cache-Control", "no-store")
.contentType(MediaType.TEXT_HTML).body(shell.replace(SLOT, htmlSafeJson(context)));
return renderer.render(context);
}
}
@@ -131,13 +121,6 @@ class PreviewController {
.header("Cache-Control", "no-store").build();
}
String htmlSafeJson(Object value) {
// JSON in a script data block still participates in HTML parsing.
return json.writeValueAsString(value).replace("<", "\\u003c")
.replace(">", "\\u003e").replace("&", "\\u0026")
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029");
}
private static class State {
String step = "identity";
String name = "";
@@ -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();
}
}
+4
View File
@@ -0,0 +1,4 @@
fixture.p12 是仅用于隔离 LDAPS 测试的自签名证书和测试私钥,口令为 fixture-only。
仅信任 localhost,不能用于生产。测试账户和密码都是虚构数据。
测试服务仅绑定 127.0.0.1;JVM/Native 均执行真实 TLS、bind 和搜索,但 AD 的 UPN bind
与禁用/锁定/密码过期子码由拦截器模拟,不代替 Samba AD 人类验收。
Binary file not shown.