按 DDD 重构认证流程并复用用户 bind 实现 LDAP 仓储

This commit is contained in:
2026-09-27 16:54:54 +00:00
parent 14f69a8a7c
commit a4b99d54f3
43 changed files with 1143 additions and 1167 deletions
@@ -1,153 +0,0 @@
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}))");
// AD domain searches include other naming-context references. Ignore them (never follow
// with user credentials); Spring's AD provider already ignores partial-result exceptions.
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", "ignore"));
// 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();
}
}
@@ -1,12 +0,0 @@
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,50 @@
package top.ddupan.iam.login.authentication.application;
import java.time.Clock;
import java.util.UUID;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.domain.UserRepository;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.domain.LoginTransaction;
/** Coordinates the first-factor use case. HTTP/session/LDAP details stay in adapters. */
public final class SignInService {
public enum PasswordResult { ACCEPTED, REJECTED, EXPIRED, WRONG_STEP, RETRY_LATER }
private final PasswordAuthenticator authenticator;
private final Clock clock;
private final boolean enabled;
public SignInService(PasswordAuthenticator authenticator, Clock clock, boolean enabled) {
this.authenticator = authenticator;
this.clock = clock;
this.enabled = enabled;
}
public boolean enabled() { return enabled; }
public LoginTransaction start() { return LoginTransaction.start(UUID.randomUUID(), clock.instant()); }
public boolean expired(LoginTransaction transaction) { return transaction.expiredAt(clock.instant()); }
public PasswordResult submitPassword(LoginTransaction transaction, String username, String password) {
if (!enabled) throw new IllegalStateException("Human sign-in is disabled");
var attempt = transaction.beginPasswordAttempt(clock.instant());
if (attempt != LoginTransaction.Attempt.ALLOWED) {
return switch (attempt) {
case EXPIRED -> PasswordResult.EXPIRED;
case WRONG_STEP -> PasswordResult.WRONG_STEP;
case RETRY_LATER -> PasswordResult.RETRY_LATER;
case ALLOWED -> throw new IllegalStateException("Unexpected attempt result");
};
}
try (var session = authenticator.authenticate(username, password)) {
var identity = session.users().findByLoginName(session.loginName());
if (identity.isEmpty()) return PasswordResult.REJECTED;
// A slow directory response must not revive an expired transaction.
var verifiedAt = clock.instant();
if (transaction.expiredAt(verifiedAt)) return PasswordResult.EXPIRED;
transaction.passwordVerified(identity.orElseThrow(), verifiedAt);
return PasswordResult.ACCEPTED;
} catch (PasswordVerificationException | UserRepository.AccessFailure ex) {
return PasswordResult.REJECTED;
}
}
}
@@ -0,0 +1,10 @@
package top.ddupan.iam.login.authentication.application.port;
import top.ddupan.iam.login.authentication.domain.UserRepository;
/** Uses the authenticated user's connection, without exposing connection or credential objects. */
public interface AuthenticatedUserSession extends AutoCloseable {
String loginName();
UserRepository users();
@Override void close();
}
@@ -0,0 +1,6 @@
package top.ddupan.iam.login.authentication.application.port;
/** Password authentication opens a short-lived user repository scope. */
public interface PasswordAuthenticator {
AuthenticatedUserSession authenticate(String username, String password);
}
@@ -0,0 +1,14 @@
package top.ddupan.iam.login.authentication.application.port;
/** Adapter failures translated into application vocabulary, without vendor exception details. */
public final class PasswordVerificationException extends RuntimeException {
public enum Reason { REJECTED, DISABLED, LOCKED, PASSWORD_EXPIRED, ACCOUNT_EXPIRED, UNAVAILABLE }
private final Reason reason;
public PasswordVerificationException(Reason reason) {
super(reason.name());
this.reason = reason;
}
public Reason reason() { return reason; }
}
@@ -0,0 +1,56 @@
package top.ddupan.iam.login.authentication.domain;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Owns first-factor ordering, lifetime and the identity to which further factors must bind. */
public final class LoginTransaction {
private static final Duration LIFETIME = Duration.ofMinutes(10);
private static final Duration ATTEMPT_INTERVAL = Duration.ofSeconds(2);
public enum Step { PASSWORD_REQUIRED, MFA_REQUIRED }
public enum Attempt { ALLOWED, EXPIRED, WRONG_STEP, RETRY_LATER }
private final UUID id;
private Step step = Step.PASSWORD_REQUIRED;
private Instant expiresAt;
private Instant retryAfter = Instant.MIN;
private User identity;
private LoginTransaction(UUID id, Instant now) {
this.id = Objects.requireNonNull(id);
this.expiresAt = now.plus(LIFETIME);
}
public static LoginTransaction start(UUID id, Instant now) {
return new LoginTransaction(id, now);
}
public Attempt beginPasswordAttempt(Instant now) {
if (expiredAt(now)) return Attempt.EXPIRED;
if (step != Step.PASSWORD_REQUIRED) return Attempt.WRONG_STEP;
if (now.isBefore(retryAfter)) return Attempt.RETRY_LATER;
retryAfter = now.plus(ATTEMPT_INTERVAL);
return Attempt.ALLOWED;
}
public void passwordVerified(User identity, Instant now) {
if (expiredAt(now) || step != Step.PASSWORD_REQUIRED) {
throw new IllegalStateException("Password verification is not allowed in this transaction state");
}
this.identity = Objects.requireNonNull(identity);
this.step = Step.MFA_REQUIRED;
this.expiresAt = now.plus(LIFETIME);
}
public UUID id() { return id; }
public Step step() { return step; }
public boolean expiredAt(Instant now) { return !now.isBefore(expiresAt); }
public User identity() {
if (identity == null) throw new IllegalStateException("No verified identity yet");
return identity;
}
}
@@ -0,0 +1,48 @@
package top.ddupan.iam.login.authentication.domain;
import java.util.List;
import java.util.Objects;
/** Directory-owned user aggregate. Identity is stable while profile and membership may change. */
public final class User {
private final UserId id;
private final String username;
private final String displayName;
private final String email;
private final List<GroupMembership> memberships;
public User(UserId id, String username, String displayName, String email, List<GroupMembership> memberships) {
this.id = Objects.requireNonNull(id);
if (username == null || username.isBlank()) throw new IllegalArgumentException("Missing username");
this.username = username;
this.displayName = Objects.requireNonNull(displayName);
this.email = Objects.requireNonNull(email);
this.memberships = List.copyOf(memberships);
}
public UserId id() { return id; }
public String username() { return username; }
public String displayName() { return displayName; }
public String email() { return email; }
public List<GroupMembership> memberships() { return memberships; }
@Override public boolean equals(Object other) { return other instanceof User user && id.equals(user.id); }
@Override public int hashCode() { return id.hashCode(); }
public record UserId(String authority, String value) {
public UserId {
if (authority == null || authority.isBlank() || value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing user identifier");
}
}
}
/** External group identifiers are opaque to the domain. */
public record GroupMembership(String name, String externalId) {
public GroupMembership {
if (name == null || name.isBlank() || externalId == null || externalId.isBlank()) {
throw new IllegalArgumentException("Missing group identifier");
}
}
}
}
@@ -0,0 +1,12 @@
package top.ddupan.iam.login.authentication.domain;
import java.util.Optional;
/** Read-only user collection; directory entries and LDAP types never cross this boundary. */
public interface UserRepository {
Optional<User> findByLoginName(String loginName);
final class AccessFailure extends RuntimeException {
public AccessFailure() { super("User repository is unavailable or returned an invalid user"); }
}
}
@@ -0,0 +1,27 @@
package top.ddupan.iam.login.authentication.infrastructure.ad;
import java.util.Locale;
import javax.naming.NamingException;
import org.springframework.LdapDataEntry;
import org.springframework.ldap.odm.core.impl.DefaultObjectDirectoryMapper;
import top.ddupan.iam.login.authentication.domain.UserRepository;
/** Keep Spring's ODM mapping while refusing silently truncated AD group attributes. */
final class AdEntryMapper extends DefaultObjectDirectoryMapper {
@Override
public <T> T mapFromLdapDataEntry(LdapDataEntry entry, Class<T> type) {
var ids = entry.getAttributes().getIDs();
try {
while (ids.hasMore()) {
if (ids.next().toLowerCase(Locale.ROOT).startsWith("memberof;")) {
throw new UserRepository.AccessFailure();
}
}
} catch (NamingException ex) {
throw new UserRepository.AccessFailure();
} finally {
try { ids.close(); } catch (NamingException ignored) { }
}
return super.mapFromLdapDataEntry(entry, type);
}
}
@@ -0,0 +1,91 @@
package top.ddupan.iam.login.authentication.infrastructure.ad;
import java.net.URI;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.ldap.repository.support.SimpleLdapRepository;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.ldap.core.support.SingleContextSource;
import org.springframework.stereotype.Component;
import top.ddupan.iam.login.authentication.application.port.AuthenticatedUserSession;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException;
import top.ddupan.iam.login.authentication.application.port.PasswordVerificationException.Reason;
/** Bind authenticates; the scoped Spring Data repository owns all user and membership reads. */
@Component
@EnableConfigurationProperties(AdProperties.class)
public final class AdPasswordAuthenticator implements PasswordAuthenticator {
private static final Pattern AD_SUBCODE = Pattern.compile("\\bdata\\s+([0-9a-f]+)", Pattern.CASE_INSENSITIVE);
private final AdProperties properties;
private final LdapContextSource contexts;
public AdPasswordAuthenticator(AdProperties properties) {
this.properties = properties;
if (!properties.enabled()) { contexts = null; return; }
var 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");
}
contexts = new LdapContextSource();
contexts.setUrl(properties.url());
contexts.setBase(properties.baseDn());
contexts.setPooled(false);
contexts.setReferral("ignore");
contexts.setBaseEnvironmentProperties(Map.of(
"com.sun.jndi.ldap.connect.timeout", "3000",
"com.sun.jndi.ldap.read.timeout", "5000",
"java.naming.ldap.attributes.binary", "objectGUID"));
contexts.afterPropertiesSet();
}
@Override
public AuthenticatedUserSession authenticate(String username, String password) {
if (contexts == null) throw new PasswordVerificationException(Reason.UNAVAILABLE);
if (username == null || username.isBlank() || username.length() > 256
|| username.contains("\\") || !username.equals(username.strip())
|| (username.contains("@") && !username.toLowerCase(Locale.ROOT)
.endsWith("@" + properties.domain().toLowerCase(Locale.ROOT)))
|| password == null || password.isEmpty() || password.length() > 1024) {
throw new PasswordVerificationException(Reason.REJECTED);
}
String principal = username.contains("@") ? username : username + "@" + properties.domain();
try {
var connection = new SingleContextSource(contexts.getContext(principal, password));
try {
var mapper = new AdEntryMapper();
var operations = new LdapTemplate(connection);
operations.setIgnorePartialResultException(true);
operations.setObjectDirectoryMapper(mapper);
var entries = new SimpleLdapRepository<>(operations, mapper, AdUserEntry.class);
return new AdUserRepository(entries, connection, properties.domain(), principal);
} catch (RuntimeException ex) {
connection.destroy();
throw ex;
}
} catch (org.springframework.ldap.AuthenticationException ex) {
throw new PasswordVerificationException(reason(ex));
} catch (org.springframework.ldap.NamingException | org.springframework.dao.DataAccessException ex) {
throw new PasswordVerificationException(Reason.UNAVAILABLE);
}
}
private static Reason reason(org.springframework.ldap.AuthenticationException exception) {
// Translate AD's diagnostic codes; never expose the diagnostic or credentials to the use case.
var matcher = AD_SUBCODE.matcher(exception.getMessage() == null ? "" : exception.getMessage());
if (!matcher.find()) return Reason.REJECTED;
return switch (matcher.group(1).toLowerCase(Locale.ROOT)) {
case "533" -> Reason.DISABLED;
case "775" -> Reason.LOCKED;
case "532", "773" -> Reason.PASSWORD_EXPIRED;
case "701" -> Reason.ACCOUNT_EXPIRED;
default -> Reason.REJECTED;
};
}
}
@@ -1,4 +1,4 @@
package top.ddupan.iam.login.ad;
package top.ddupan.iam.login.authentication.infrastructure.ad;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -0,0 +1,20 @@
package top.ddupan.iam.login.authentication.infrastructure.ad;
import java.util.List;
import javax.naming.Name;
import lombok.Getter;
import org.springframework.ldap.odm.annotations.Attribute;
import org.springframework.ldap.odm.annotations.Entry;
import org.springframework.ldap.odm.annotations.Id;
/** Persistence representation, deliberately separate from the domain user. */
@Getter
@Entry(objectClasses = "user")
public final class AdUserEntry {
@Id private Name dn;
@Attribute(name = "objectGUID", type = Attribute.Type.BINARY) private byte[] objectGuid;
@Attribute(name = "sAMAccountName") private String accountName;
@Attribute(name = "displayName") private String displayName;
@Attribute(name = "mail") private String email;
@Attribute(name = "memberOf") private List<String> memberOf;
}
@@ -0,0 +1,82 @@
package top.ddupan.iam.login.authentication.infrastructure.ad;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.Optional;
import java.util.TreeMap;
import java.util.UUID;
import javax.naming.NamingException;
import javax.naming.ldap.LdapName;
import org.springframework.data.ldap.repository.LdapRepository;
import org.springframework.ldap.core.support.SingleContextSource;
import top.ddupan.iam.login.authentication.application.port.AuthenticatedUserSession;
import top.ddupan.iam.login.authentication.domain.User;
import top.ddupan.iam.login.authentication.domain.User.GroupMembership;
import top.ddupan.iam.login.authentication.domain.User.UserId;
import top.ddupan.iam.login.authentication.domain.UserRepository;
import static org.springframework.ldap.query.LdapQueryBuilder.query;
/** One authenticated connection and one Spring Data repository per use-case scope. */
final class AdUserRepository implements UserRepository, AuthenticatedUserSession {
private final LdapRepository<AdUserEntry> entries;
private final SingleContextSource connection;
private final String authority;
private final String loginName;
private boolean closed;
AdUserRepository(LdapRepository<AdUserEntry> entries, SingleContextSource connection,
String authority, String loginName) {
this.entries = entries;
this.connection = connection;
this.authority = authority;
this.loginName = loginName;
}
@Override
public Optional<User> findByLoginName(String name) {
requireOpen();
try {
return entries.findOne(query().where("objectClass").is("user")
.and("objectClass").not().is("computer")
.and(query().where("sAMAccountName").is(name).or("userPrincipalName").is(name)))
.map(this::toUser);
} catch (org.springframework.ldap.NamingException | org.springframework.dao.DataAccessException | IllegalArgumentException ex) {
throw new UserRepository.AccessFailure();
}
}
private User toUser(AdUserEntry entry) {
var groups = new TreeMap<String, GroupMembership>();
for (String dn : entry.getMemberOf() == null ? List.<String>of() : entry.getMemberOf()) {
try {
var name = new LdapName(dn);
var rdn = name.getRdn(name.size() - 1);
if (!rdn.getType().equalsIgnoreCase("CN")) throw new UserRepository.AccessFailure();
String cn = rdn.getValue().toString();
if (groups.putIfAbsent(cn, new GroupMembership(cn, dn)) != null) {
throw new UserRepository.AccessFailure();
}
} catch (NamingException ex) { throw new UserRepository.AccessFailure(); }
}
return new User(new UserId(authority, guid(entry.getObjectGuid())), entry.getAccountName(),
entry.getDisplayName() == null ? entry.getAccountName() : entry.getDisplayName(),
entry.getEmail() == null ? "" : entry.getEmail(), List.copyOf(groups.values()));
}
static String guid(byte[] bytes) {
if (bytes == null || bytes.length != 16) throw new UserRepository.AccessFailure();
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());
return new UUID(most, ByteBuffer.wrap(bytes, 8, 8).getLong()).toString();
}
@Override public String loginName() { requireOpen(); return loginName; }
@Override public UserRepository users() { requireOpen(); return this; }
private void requireOpen() { if (closed) throw new IllegalStateException("User repository scope is closed"); }
@Override public void close() {
if (!closed) { closed = true; connection.destroy(); }
}
}
@@ -0,0 +1,12 @@
package top.ddupan.iam.login.authentication.interfaces.web;
import top.ddupan.iam.login.authentication.domain.LoginTransaction;
/** HTTP-session storage plus presentation feedback. Authentication rules live in the aggregate. */
final class BrowserSignInState {
final LoginTransaction transaction;
String username = "";
String error = "";
BrowserSignInState(LoginTransaction transaction) { this.transaction = transaction; }
}
@@ -1,4 +1,4 @@
package top.ddupan.iam.login.preview;
package top.ddupan.iam.login.authentication.interfaces.web;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -1,30 +1,30 @@
package top.ddupan.iam.login.ad;
package top.ddupan.iam.login.authentication.interfaces.web;
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;
import top.ddupan.iam.login.authentication.application.SignInService;
import top.ddupan.iam.login.authentication.domain.LoginTransaction.Step;
import top.ddupan.iam.login.authentication.domain.User.GroupMembership;
/** Human first-factor PoC. No SecurityContext, MFA acceptance, or Hydra calls. */
/** Translates browser requests and use-case outcomes; no directory or authentication policy here. */
@RestController
public class AdLoginController {
static final String STATE = AdLoginController.class.getName() + ".state";
private final AdPasswordVerifier verifier;
public class SignInController {
static final String STATE = SignInController.class.getName() + ".state";
private final SignInService signIn;
private final PageRenderer renderer;
public AdLoginController(AdPasswordVerifier verifier, PageRenderer renderer) {
this.verifier = verifier;
public SignInController(SignInService signIn, PageRenderer renderer) {
this.signIn = signIn;
this.renderer = renderer;
}
@@ -34,7 +34,7 @@ public class AdLoginController {
var session = request.getSession();
synchronized (session) {
var state = state(session);
if (state.identity != null) return redirect("/signin/mfa");
if (state.transaction.step() == Step.MFA_REQUIRED) return redirect("/signin/mfa");
return renderer.render(Map.of("step", "password", "name", state.username,
"error", state.error, "action", "/signin/password", "csrf", csrf(csrf)));
}
@@ -48,26 +48,22 @@ public class AdLoginController {
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);
var state = (BrowserSignInState) session.getAttribute(STATE);
if (state == null) return redirect("/signin");
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");
}
return switch (signIn.submitPassword(state.transaction, username, password)) {
case ACCEPTED -> {
request.changeSessionId();
state.error = "";
yield redirect("/signin/mfa");
}
case REJECTED -> {
state.error = "无法验证账号,请检查凭据与账号状态,或稍后重试。";
yield redirect("/signin");
}
case EXPIRED, WRONG_STEP -> redirect("/signin");
case RETRY_LATER -> throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS);
};
}
}
@@ -78,12 +74,14 @@ public class AdLoginController {
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(),
if (state.transaction.step() != Step.MFA_REQUIRED) return redirect("/signin");
var user = state.transaction.identity();
return renderer.render(Map.of("step", "mfa-pending", "name", user.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())));
"identity", Map.of("username", user.username(), "subjectId", user.id().value(),
"email", user.email(),
"groups", user.memberships().stream().map(GroupMembership::name).toList(),
"groupDns", user.memberships().stream().map(GroupMembership::externalId).toList())));
}
}
@@ -96,33 +94,25 @@ public class AdLoginController {
}
private void requireAvailable(HttpServletRequest request) {
if (!verifier.enabled()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (!signIn.enabled()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
if (!request.isSecure()) throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED, "HTTPS required");
}
private BrowserSignInState state(HttpSession session) {
var state = (BrowserSignInState) session.getAttribute(STATE);
if (state == null || signIn.expired(state.transaction)) {
state = new BrowserSignInState(signIn.start());
session.setAttribute(STATE, state);
}
return state;
}
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,34 @@
package top.ddupan.iam.login.configuration;
import java.time.Clock;
import javax.naming.directory.DirContext;
import javax.naming.ldap.LdapContext;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.ldap.core.DirContextProxy;
import top.ddupan.iam.login.authentication.infrastructure.ad.AdUserEntry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.ddupan.iam.login.authentication.application.SignInService;
import top.ddupan.iam.login.authentication.application.port.PasswordAuthenticator;
import top.ddupan.iam.login.authentication.infrastructure.ad.AdProperties;
/** Composition root: dependencies point inward, framework wiring stays outside the model. */
@Configuration(proxyBeanMethods = false)
@RegisterReflectionForBinding(AdUserEntry.class)
@ImportRuntimeHints(AuthenticationConfiguration.DirectoryHints.class)
class AuthenticationConfiguration {
@Bean
SignInService signInService(PasswordAuthenticator authenticator, AdProperties properties) {
return new SignInService(authenticator, Clock.systemUTC(), properties.enabled());
}
static class DirectoryHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.proxies().registerJdkProxy(LdapContext.class, DirContextProxy.class);
hints.proxies().registerJdkProxy(DirContext.class, DirContextProxy.class);
}
}
}
@@ -0,0 +1,22 @@
package top.ddupan.iam.login.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/signin", "/signin/**", "/assets/**", "/actuator/health/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
.build();
}
}
@@ -0,0 +1,28 @@
package top.ddupan.iam.login.configuration;
import java.time.Duration;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(WebConfiguration.Resources.class)
class WebConfiguration implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/ui/assets/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable());
}
static class Resources implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("ui/**");
}
}
}
@@ -1,44 +0,0 @@
package top.ddupan.iam.login.preview;
import java.time.Duration;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.http.CacheControl;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(PreviewConfiguration.Resources.class)
class PreviewConfiguration implements WebMvcConfigurer {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/signin", "/signin/**", "/preview", "/preview/**", "/assets/**", "/actuator/health/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
.build();
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/ui/assets/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable());
}
static class Resources implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("ui/**");
}
}
}
@@ -1,129 +0,0 @@
package top.ddupan.iam.login.preview;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
/** An isolated UI experiment. It never creates an authenticated SecurityContext. */
@RestController
class PreviewController {
private static final String STATE = PreviewController.class.getName() + ".state";
private final boolean enabled;
private final PageRenderer renderer;
PreviewController(@Value("${iam.ui-preview.enabled:false}") boolean enabled, PageRenderer renderer) {
this.enabled = enabled;
this.renderer = renderer;
}
@GetMapping(value = {"/preview", "/preview/verify", "/preview/complete"}, produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> page(HttpServletRequest request, CsrfToken csrf) {
requireEnabled();
var session = request.getSession();
synchronized (session) {
var state = state(session);
var path = request.getRequestURI().substring(request.getContextPath().length());
if (path.equals("/preview")) {
if (!state.step.equals("identity")) state.error = "";
state.step = "identity";
} else if (!path.equals(pathFor(state.step))) {
return redirect(pathFor(state.step));
}
var context = Map.of("step", state.step, "name", state.name, "error", state.error,
"action", switch (state.step) {
case "identity" -> "/preview/identify";
case "verification" -> "/preview/verify";
default -> "/preview/restart";
}, "csrf", Map.of("name", csrf.getParameterName(), "value", csrf.getToken()));
return renderer.render(context);
}
}
@PostMapping("/preview/identify")
ResponseEntity<String> identify(@RequestParam(defaultValue = "") String name, HttpSession session) {
requireEnabled();
synchronized (session) {
var state = state(session);
requireStep(state, "identity");
if (name.isBlank() || name.length() > 64) {
state.error = "称呼须为 1 到 64 个字符。";
return redirect("/preview");
}
state.name = name.strip();
state.error = "";
state.step = "verification";
return redirect("/preview/verify");
}
}
@PostMapping("/preview/verify")
ResponseEntity<String> verify(@RequestParam(defaultValue = "") String code, HttpSession session) {
requireEnabled();
synchronized (session) {
var state = state(session);
requireStep(state, "verification");
if (!code.equals("123456")) {
state.error = "演示码不正确,请输入 123456。";
return redirect("/preview/verify");
}
state.error = "";
state.step = "complete";
return redirect("/preview/complete");
}
}
@PostMapping("/preview/restart")
ResponseEntity<String> restart(HttpSession session) {
requireEnabled();
synchronized (session) {
session.removeAttribute(STATE);
return redirect("/preview");
}
}
private void requireEnabled() {
if (!enabled) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
private static void requireStep(State state, String step) {
if (!state.step.equals(step)) throw new ResponseStatusException(HttpStatus.CONFLICT, "页面已过期,请重新打开预览");
}
private static State state(HttpSession session) {
var state = (State) session.getAttribute(STATE);
if (state == null) {
state = new State();
session.setAttribute(STATE, state);
}
return state;
}
private static String pathFor(String step) {
return switch (step) {
case "verification" -> "/preview/verify";
case "complete" -> "/preview/complete";
default -> "/preview";
};
}
private static ResponseEntity<String> redirect(String path) {
return ResponseEntity.status(HttpStatus.SEE_OTHER).header("Location", path)
.header("Cache-Control", "no-store").build();
}
private static class State {
String step = "identity";
String name = "";
String error = "";
}
}