接入 AD 密码验证与直接所属组,保留待 MFA 边界
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user