接入 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 = "";