按 DDD 重构认证流程并复用用户 bind 实现 LDAP 仓储
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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();
|
||||
}
|
||||
+6
@@ -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);
|
||||
}
|
||||
+14
@@ -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"); }
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+91
@@ -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
-1
@@ -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;
|
||||
}
|
||||
+82
@@ -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(); }
|
||||
}
|
||||
}
|
||||
+12
@@ -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
-1
@@ -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;
|
||||
+43
-53
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user