|
@@ -2,42 +2,84 @@ package org.springframework.security.ldap.authentication.ad;
|
|
|
|
|
|
import org.springframework.ldap.core.DirContextOperations;
|
|
|
import org.springframework.ldap.core.DistinguishedName;
|
|
|
+import org.springframework.ldap.support.LdapUtils;
|
|
|
+import org.springframework.security.authentication.AccountExpiredException;
|
|
|
import org.springframework.security.authentication.BadCredentialsException;
|
|
|
+import org.springframework.security.authentication.CredentialsExpiredException;
|
|
|
+import org.springframework.security.authentication.DisabledException;
|
|
|
+import org.springframework.security.authentication.LockedException;
|
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
import org.springframework.security.core.GrantedAuthority;
|
|
|
import org.springframework.security.core.authority.AuthorityUtils;
|
|
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
|
-import org.springframework.security.ldap.LdapUtils;
|
|
|
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
|
|
|
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
|
|
|
import org.springframework.util.Assert;
|
|
|
import org.springframework.util.StringUtils;
|
|
|
|
|
|
+import javax.naming.AuthenticationException;
|
|
|
import javax.naming.Context;
|
|
|
import javax.naming.NamingException;
|
|
|
+import javax.naming.OperationNotSupportedException;
|
|
|
import javax.naming.directory.SearchControls;
|
|
|
import javax.naming.ldap.InitialLdapContext;
|
|
|
import javax.naming.ldap.LdapContext;
|
|
|
import java.util.*;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
|
/**
|
|
|
* Specialized LDAP authentication provider which uses Active Directory configuration conventions.
|
|
|
* <p>
|
|
|
- * It will authenticate using the Active Directory {@code userPrincipalName} (in the form {@code username@domain}).
|
|
|
- * If the {@code usernameIncludesDomain} property is set to {@code true}, it is assumed that the user types in the
|
|
|
- * full value, including the domain. Otherwise (the default), the {@code userPrincipalName} will be built from the
|
|
|
- * username supplied in the authentication request.
|
|
|
+ * It will authenticate using the Active Directory
|
|
|
+ * <a href="http://msdn.microsoft.com/en-us/library/ms680857%28VS.85%29.aspx">{@code userPrincipalName}</a>
|
|
|
+ * (in the form {@code username@domain}). If the username does not already end with the domain name, the
|
|
|
+ * {@code userPrincipalName} will be built by appending the configured domain name to the username supplied in the
|
|
|
+ * authentication request. If no domain name is configured, it is assumed that the username will always contain the
|
|
|
+ * domain name.
|
|
|
* <p>
|
|
|
* The user authorities are obtained from the data contained in the {@code memberOf} attribute.
|
|
|
*
|
|
|
+ * <h3>Active Directory Sub-Error Codes</h3>
|
|
|
+ *
|
|
|
+ * When an authentication fails, resulting in a standard LDAP 49 error code, Active Directory also supplies its own
|
|
|
+ * sub-error codes within the error message. These will be used to provide additional log information on why an
|
|
|
+ * authentication has failed. Typical examples are
|
|
|
+ *
|
|
|
+ * <ul>
|
|
|
+ * <li>525 - user not found</li>
|
|
|
+ * <li>52e - invalid credentials</li>
|
|
|
+ * <li>530 - not permitted to logon at this time</li>
|
|
|
+ * <li>532 - password expired</li>
|
|
|
+ * <li>533 - account disabled</li>
|
|
|
+ * <li>701 - account expired</li>
|
|
|
+ * <li>773 - user must reset password</li>
|
|
|
+ * <li>775 - account locked</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * If you set the {@link #setConvertSubErrorCodesToExceptions(boolean) convertSubErrorCodesToExceptions} property to
|
|
|
+ * {@code true}, the codes will also be used to control the exception raised.
|
|
|
+ *
|
|
|
* @author Luke Taylor
|
|
|
* @since 3.1
|
|
|
*/
|
|
|
public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLdapAuthenticationProvider {
|
|
|
+ private static final Pattern SUB_ERROR_CODE = Pattern.compile(".*\\s([0-9a-f]{3,4}).*");
|
|
|
+
|
|
|
+ // Error codes
|
|
|
+ private static final int USERNAME_NOT_FOUND = 0x525;
|
|
|
+ private static final int INVALID_PASSWORD = 0x52e;
|
|
|
+ private static final int NOT_PERMITTED = 0x530;
|
|
|
+ private static final int PASSWORD_EXPIRED = 0x532;
|
|
|
+ private static final int ACCOUNT_DISABLED = 0x533;
|
|
|
+ private static final int ACCOUNT_EXPIRED = 0x701;
|
|
|
+ private static final int PASSWORD_NEEDS_RESET = 0x773;
|
|
|
+ private static final int ACCOUNT_LOCKED = 0x775;
|
|
|
+
|
|
|
private final String domain;
|
|
|
private final String rootDn;
|
|
|
private final String url;
|
|
|
- private boolean usernameIncludesDomain = false;
|
|
|
+ private boolean convertSubErrorCodesToExceptions;
|
|
|
|
|
|
/**
|
|
|
* @param domain the domain for which authentication should take place
|
|
@@ -52,7 +94,7 @@ public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLda
|
|
|
*/
|
|
|
public ActiveDirectoryLdapAuthenticationProvider(String domain, String url) {
|
|
|
Assert.isTrue(StringUtils.hasText(domain) || StringUtils.hasText(url), "Domain and url cannot both be empty");
|
|
|
- this.domain = StringUtils.hasText(domain) ? domain : null;
|
|
|
+ this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
|
|
|
this.url = StringUtils.hasText(url) ? url : null;
|
|
|
rootDn = this.domain == null ? null : rootDnFromDomain(this.domain);
|
|
|
}
|
|
@@ -68,8 +110,8 @@ public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLda
|
|
|
return searchForUser(ctx, username);
|
|
|
|
|
|
} catch (NamingException e) {
|
|
|
- logger.error("Failed to locate directory entry for authentication user: " + username, e);
|
|
|
- throw authenticationFailure();
|
|
|
+ logger.error("Failed to locate directory entry for authenticated user: " + username, e);
|
|
|
+ throw badCredentials();
|
|
|
} finally {
|
|
|
LdapUtils.closeContext(ctx);
|
|
|
}
|
|
@@ -108,7 +150,8 @@ public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLda
|
|
|
|
|
|
Hashtable<String,String> env = new Hashtable<String,String>();
|
|
|
env.put(Context.SECURITY_AUTHENTICATION, "simple");
|
|
|
- env.put(Context.SECURITY_PRINCIPAL, createBindPrincipal(username));
|
|
|
+ String bindPrincipal = createBindPrincipal(username);
|
|
|
+ env.put(Context.SECURITY_PRINCIPAL, bindPrincipal);
|
|
|
env.put(Context.PROVIDER_URL, bindUrl);
|
|
|
env.put(Context.SECURITY_CREDENTIALS, password);
|
|
|
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
|
|
@@ -116,12 +159,84 @@ public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLda
|
|
|
try {
|
|
|
return new InitialLdapContext(env, null);
|
|
|
} catch (NamingException e) {
|
|
|
- logger.debug("Authentication failed", e);
|
|
|
- throw authenticationFailure();
|
|
|
+ if ((e instanceof AuthenticationException) || (e instanceof OperationNotSupportedException)) {
|
|
|
+ handleBindException(bindPrincipal, e);
|
|
|
+ throw badCredentials();
|
|
|
+ } else {
|
|
|
+ throw LdapUtils.convertLdapException(e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void handleBindException(String bindPrincipal, NamingException exception) {
|
|
|
+ if (logger.isDebugEnabled()) {
|
|
|
+ logger.debug("Authentication for " + bindPrincipal + " failed:" + exception);
|
|
|
+ }
|
|
|
+
|
|
|
+ int subErrorCode = parseSubErrorCode(exception.getMessage());
|
|
|
+
|
|
|
+ if (subErrorCode > 0) {
|
|
|
+ logger.info("Active Directory authentication failed: " + subCodeToLogMessage(subErrorCode));
|
|
|
+
|
|
|
+ if (convertSubErrorCodesToExceptions) {
|
|
|
+ raiseExceptionForErrorCode(subErrorCode);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logger.debug("Failed to locate AD-specific sub-error code in message");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private BadCredentialsException authenticationFailure() {
|
|
|
+ int parseSubErrorCode(String message) {
|
|
|
+ Matcher m = SUB_ERROR_CODE.matcher(message);
|
|
|
+
|
|
|
+ if (m.matches()) {
|
|
|
+ return Integer.parseInt(m.group(1), 16);
|
|
|
+ }
|
|
|
+
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ void raiseExceptionForErrorCode(int code) {
|
|
|
+ switch (code) {
|
|
|
+ case PASSWORD_EXPIRED:
|
|
|
+ throw new CredentialsExpiredException(messages.getMessage("LdapAuthenticationProvider.credentialsExpired",
|
|
|
+ "User credentials have expired"));
|
|
|
+ case ACCOUNT_DISABLED:
|
|
|
+ throw new DisabledException(messages.getMessage("LdapAuthenticationProvider.disabled",
|
|
|
+ "User is disabled"));
|
|
|
+ case ACCOUNT_EXPIRED:
|
|
|
+ throw new AccountExpiredException(messages.getMessage("LdapAuthenticationProvider.expired",
|
|
|
+ "User account has expired"));
|
|
|
+ case ACCOUNT_LOCKED:
|
|
|
+ throw new LockedException(messages.getMessage("LdapAuthenticationProvider.locked",
|
|
|
+ "User account is locked"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ String subCodeToLogMessage(int code) {
|
|
|
+ switch (code) {
|
|
|
+ case USERNAME_NOT_FOUND:
|
|
|
+ return "User was not found in directory";
|
|
|
+ case INVALID_PASSWORD:
|
|
|
+ return "Supplied password was invalid";
|
|
|
+ case NOT_PERMITTED:
|
|
|
+ return "User not permitted to logon at this time";
|
|
|
+ case PASSWORD_EXPIRED:
|
|
|
+ return "Password has expired";
|
|
|
+ case ACCOUNT_DISABLED:
|
|
|
+ return "Account is disabled";
|
|
|
+ case ACCOUNT_EXPIRED:
|
|
|
+ return "Account expired";
|
|
|
+ case PASSWORD_NEEDS_RESET:
|
|
|
+ return "User must reset password";
|
|
|
+ case ACCOUNT_LOCKED:
|
|
|
+ return "Account locked";
|
|
|
+ }
|
|
|
+
|
|
|
+ return "Unknown (error code " + Integer.toHexString(code) +")";
|
|
|
+ }
|
|
|
+
|
|
|
+ private BadCredentialsException badCredentials() {
|
|
|
return new BadCredentialsException(messages.getMessage(
|
|
|
"LdapAuthenticationProvider.badCredentials", "Bad credentials"));
|
|
|
}
|
|
@@ -158,18 +273,27 @@ public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLda
|
|
|
return root.toString();
|
|
|
}
|
|
|
|
|
|
- private String createBindPrincipal(String username) {
|
|
|
- if (usernameIncludesDomain || domain == null) {
|
|
|
+ String createBindPrincipal(String username) {
|
|
|
+ if (domain == null || username.toLowerCase().endsWith(domain)) {
|
|
|
return username;
|
|
|
}
|
|
|
|
|
|
return username + "@" + domain;
|
|
|
}
|
|
|
|
|
|
- public void setUsernameIncludesDomain(boolean usernameIncludesDomain) {
|
|
|
- Assert.isTrue(domain != null || usernameIncludesDomain,
|
|
|
- "If the domain name is not included in the username, a domain must be set in the constructor");
|
|
|
- this.usernameIncludesDomain = usernameIncludesDomain;
|
|
|
+ /**
|
|
|
+ * By default, a failed authentication (LDAP error 49) will result in a {@code BadCredentialsException}.
|
|
|
+ * <p>
|
|
|
+ * If this property is set to {@code true}, the exception message from a failed bind attempt will be parsed
|
|
|
+ * for the AD-specific error code and a {@link CredentialsExpiredException}, {@link DisabledException},
|
|
|
+ * {@link AccountExpiredException} or {@link LockedException} will be thrown for the corresponding codes. All
|
|
|
+ * other codes will result in the default {@code BadCredentialsException}.
|
|
|
+ *
|
|
|
+ * @param convertSubErrorCodesToExceptions {@code true} to raise an exception based on the AD error code.
|
|
|
+ */
|
|
|
+ public void setConvertSubErrorCodesToExceptions(boolean convertSubErrorCodesToExceptions) {
|
|
|
+ this.convertSubErrorCodesToExceptions = convertSubErrorCodesToExceptions;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
}
|