接入 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 = "";
|
||||
|
||||
Reference in New Issue
Block a user