Pārlūkot izejas kodu

SEC-1181: Added option to parse AD sub-error codes.

Luke Taylor 14 gadi atpakaļ
vecāks
revīzija
59ac4c8b96

+ 5 - 1
core/src/main/resources/org/springframework/security/messages.properties

@@ -30,6 +30,10 @@ DigestAuthenticationFilter.usernameNotFound=Username {0} not found
 JdbcDaoImpl.noAuthority=User {0} has no GrantedAuthority
 JdbcDaoImpl.notFound=User {0} not found
 LdapAuthenticationProvider.badCredentials=Bad credentials
+LdapAuthenticationProvider.credentialsExpired=User credentials have expired
+LdapAuthenticationProvider.disabled=User is disabled
+LdapAuthenticationProvider.expired=User account has expired
+LdapAuthenticationProvider.locked=User account is locked
 LdapAuthenticationProvider.emptyUsername=Empty username not allowed
 LdapAuthenticationProvider.onlySupports=Only UsernamePasswordAuthenticationToken is supported
 PasswordComparisonAuthenticator.badCredentials=Bad credentials
@@ -39,4 +43,4 @@ RememberMeAuthenticationProvider.incorrectKey=The presented RememberMeAuthentica
 RunAsImplAuthenticationProvider.incorrectKey=The presented RunAsUserToken does not contain the expected key
 SubjectDnX509PrincipalExtractor.noMatching=No matching pattern was found in subjectDN: {0}
 SwitchUserFilter.noCurrentUser=No current user associated with this request
-SwitchUserFilter.noOriginalAuthentication=Could not find original Authentication object
+SwitchUserFilter.noOriginalAuthentication=Could not find original Authentication object

+ 143 - 19
ldap/src/main/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryLdapAuthenticationProvider.java

@@ -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;
     }
 
+
 }

+ 27 - 7
ldap/src/test/java/org/springframework/security/ldap/authentication/ad/ActiveDirectoryLdapAuthenticationProviderTests.java

@@ -1,25 +1,45 @@
 package org.springframework.security.ldap.authentication.ad;
 
 import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
 
 import org.junit.*;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 
+import javax.naming.Context;
+import javax.naming.InitialContext;
+import javax.naming.NamingException;
+import javax.naming.ldap.LdapContext;
+import javax.naming.spi.InitialContextFactory;
+import javax.naming.spi.InitialContextFactoryBuilder;
+import javax.naming.spi.NamingManager;
+import java.util.*;
+
 /**
  * @author Luke Taylor
  */
 public class ActiveDirectoryLdapAuthenticationProviderTests {
 
     @Test
-    public void simpleAuthenticationWithIsSucessful() throws Exception {
+    public void bindPrincipalIsCreatedCorrectly() throws Exception {
         ActiveDirectoryLdapAuthenticationProvider provider =
-                new ActiveDirectoryLdapAuthenticationProvider(null, "ldap://192.168.1.200/");
-
-        Authentication result = provider.authenticate(new UsernamePasswordAuthenticationToken("luke@fenetres.monkeymachine.eu","p!ssw0rd"));
-
-        assertEquals(1, result.getAuthorities().size());
-        assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("blah")));
+                new ActiveDirectoryLdapAuthenticationProvider("mydomain.eu", "ldap://192.168.1.200/");
+        assertEquals("joe@mydomain.eu", provider.createBindPrincipal("joe"));
+        assertEquals("joe@mydomain.eu", provider.createBindPrincipal("joe@mydomain.eu"));
     }
+
+//    @Test
+//    public void realAuthenticationIsSucessful() throws Exception {
+//        ActiveDirectoryLdapAuthenticationProvider provider =
+//                new ActiveDirectoryLdapAuthenticationProvider(null, "ldap://192.168.1.200/");
+//
+//        provider.setConvertSubErrorCodesToExceptions(true);
+//
+//        Authentication result = provider.authenticate(new UsernamePasswordAuthenticationToken("luke@fenetres.monkeymachine.eu","p!ssw0rd"));
+//
+//        assertEquals(1, result.getAuthorities().size());
+//        assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("blah")));
+//    }
 }