001    /*
002     *  Licensed to the Apache Software Foundation (ASF) under one
003     *  or more contributor license agreements.  See the NOTICE file
004     *  distributed with this work for additional information
005     *  regarding copyright ownership.  The ASF licenses this file
006     *  to you under the Apache License, Version 2.0 (the
007     *  "License"); you may not use this file except in compliance
008     *  with the License.  You may obtain a copy of the License at
009     *  
010     *    http://www.apache.org/licenses/LICENSE-2.0
011     *  
012     *  Unless required by applicable law or agreed to in writing,
013     *  software distributed under the License is distributed on an
014     *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015     *  KIND, either express or implied.  See the License for the
016     *  specific language governing permissions and limitations
017     *  under the License. 
018     *  
019     */
020    package org.apache.directory.server.core.kerberos;
021    
022    
023    import org.apache.directory.server.core.interceptor.BaseInterceptor;
024    import org.apache.directory.server.core.interceptor.Interceptor;
025    import org.apache.directory.server.core.interceptor.NextInterceptor;
026    import org.apache.directory.server.core.interceptor.context.AddOperationContext;
027    import org.apache.directory.server.core.interceptor.context.ModifyOperationContext;
028    import org.apache.directory.shared.ldap.constants.SchemaConstants;
029    import org.apache.directory.shared.ldap.entry.BinaryValue;
030    import org.apache.directory.shared.ldap.entry.StringValue;
031    import org.apache.directory.shared.ldap.entry.EntryAttribute;
032    import org.apache.directory.shared.ldap.entry.Modification;
033    import org.apache.directory.shared.ldap.entry.ServerEntry;
034    import org.apache.directory.shared.ldap.entry.Value;
035    import org.apache.directory.shared.ldap.name.DN;
036    import org.apache.directory.shared.ldap.util.StringTools;
037    import org.slf4j.Logger;
038    import org.slf4j.LoggerFactory;
039    
040    import java.util.ArrayList;
041    import java.util.List;
042    
043    
044    /**
045     * An {@link Interceptor} that enforces password policy for users.  Add or modify operations
046     * on the 'userPassword' attribute are checked against a password policy.  The password is
047     * rejected if it does not pass the password policy checks.  The password MUST be passed to
048     * the core as plaintext.
049     * 
050     * @org.apache.xbean.XBean
051     *
052     * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
053     * @version $Rev$, $Date$
054     */
055    public class PasswordPolicyInterceptor extends BaseInterceptor
056    {
057        /** The log for this class. */
058        private static final Logger log = LoggerFactory.getLogger( PasswordPolicyInterceptor.class );
059    
060        /** The service name. */
061        public static final String NAME = "passwordPolicyService";
062    
063    
064        /**
065         * Check added attributes for a 'userPassword'.  If a 'userPassword' is found, apply any
066         * password policy checks.
067         */
068        public void add( NextInterceptor next, AddOperationContext addContext ) throws Exception
069        {
070            DN normName = addContext.getDn();
071    
072            ServerEntry entry = addContext.getEntry();
073    
074            log.debug( "Adding the entry '{}' for DN '{}'.", entry, normName.getName() );
075    
076            if ( entry.get( SchemaConstants.USER_PASSWORD_AT ) != null )
077            {
078                String username = null;
079    
080                BinaryValue userPassword = (BinaryValue)entry.get( SchemaConstants.USER_PASSWORD_AT ).get();
081    
082                // The password is stored in a non H/R attribute, but it's a String
083                String strUserPassword = userPassword.getString();
084    
085                if ( log.isDebugEnabled() )
086                {
087                    StringBuffer sb = new StringBuffer();
088                    sb.append( "'" + strUserPassword + "' ( " );
089                    sb.append( userPassword );
090                    sb.append( " )" );
091                    log.debug( "Adding Attribute id : 'userPassword',  Values : [ {} ]", sb.toString() );
092                }
093    
094                if ( entry.get( SchemaConstants.CN_AT ) != null )
095                {
096                    StringValue attr = (StringValue)entry.get( SchemaConstants.CN_AT ).get();
097                    username = attr.getString();
098                }
099    
100                // If userPassword fails checks, throw new NamingException.
101                check( username, strUserPassword );
102            }
103    
104            next.add( addContext );
105        }
106    
107    
108        /**
109         * Check modification items for a 'userPassword'.  If a 'userPassword' is found, apply any
110         * password policy checks.
111         */
112        public void modify( NextInterceptor next, ModifyOperationContext modContext ) throws Exception
113        {
114            DN name = modContext.getDn();
115    
116            List<Modification> mods = modContext.getModItems();
117    
118            String operation = null;
119    
120            for ( Modification mod:mods )
121            {
122                if ( log.isDebugEnabled() )
123                {
124                    switch ( mod.getOperation() )
125                    {
126                        case ADD_ATTRIBUTE:
127                            operation = "Adding";
128                            break;
129                            
130                        case REMOVE_ATTRIBUTE:
131                            operation = "Removing";
132                            break;
133                            
134                        case REPLACE_ATTRIBUTE:
135                            operation = "Replacing";
136                            break;
137                    }
138                }
139    
140                EntryAttribute attr = mod.getAttribute();
141    
142                if ( attr.instanceOf( SchemaConstants.USER_PASSWORD_AT ) )
143                {
144                    Value<?> userPassword = attr.get();
145                    String pwd = "";
146    
147                    if ( userPassword != null )
148                    {
149                        if ( userPassword instanceof StringValue )
150                        {
151                            log.debug( "{} Attribute id : 'userPassword',  Values : [ '{}' ]", operation, attr );
152                            pwd = ((StringValue)userPassword).getString();
153                        }
154                        else if ( userPassword instanceof BinaryValue )
155                        {
156                            BinaryValue password = (BinaryValue)userPassword;
157                            
158                            String string = password.getString();
159    
160                            if ( log.isDebugEnabled() )
161                            {
162                                StringBuffer sb = new StringBuffer();
163                                sb.append( "'" + string + "' ( " );
164                                sb.append( StringTools.dumpBytes( password.getBytes() ).trim() );
165                                sb.append( " )" );
166                                log.debug( "{} Attribute id : 'userPassword',  Values : [ {} ]", operation, sb.toString() );
167                            }
168    
169                            pwd = string;
170                        }
171    
172                        // if userPassword fails checks, throw new NamingException.
173                        check( name.getName(), pwd );
174                    }
175                }
176    
177                if ( log.isDebugEnabled() )
178                {
179                    log.debug( operation + " for entry '" + name.getName() + "' the attribute " + mod.getAttribute() );
180                }
181            }
182    
183            next.modify( modContext );
184        }
185    
186    
187        void check( String username, String password ) throws Exception
188        {
189            int passwordLength = 6;
190            int categoryCount = 2;
191            int tokenSize = 3;
192    
193            if ( !isValid( username, password, passwordLength, categoryCount, tokenSize ) )
194            {
195                String explanation = buildErrorMessage( username, password, passwordLength, categoryCount, tokenSize );
196                log.error( explanation );
197    
198                throw new Exception( explanation );
199            }
200        }
201    
202    
203        /**
204         * Tests that:
205         * The password is at least six characters long.
206         * The password contains a mix of characters.
207         * The password does not contain three letter (or more) tokens from the user's account name.
208         */
209        boolean isValid( String username, String password, int passwordLength, int categoryCount, int tokenSize )
210        {
211            return isValidPasswordLength( password, passwordLength ) && isValidCategoryCount( password, categoryCount )
212                && isValidUsernameSubstring( username, password, tokenSize );
213        }
214    
215    
216        /**
217         * The password is at least six characters long.
218         */
219        boolean isValidPasswordLength( String password, int passwordLength )
220        {
221            return password.length() >= passwordLength;
222        }
223    
224    
225        /**
226         * The password contains characters from at least three of the following four categories:
227         * English uppercase characters (A - Z)
228         * English lowercase characters (a - z)
229         * Base 10 digits (0 - 9)
230         * Any non-alphanumeric character (for example: !, $, #, or %)
231         */
232        boolean isValidCategoryCount( String password, int categoryCount )
233        {
234            int uppercase = 0;
235            int lowercase = 0;
236            int digit = 0;
237            int nonAlphaNumeric = 0;
238    
239            char[] characters = password.toCharArray();
240    
241            for ( char character:characters )
242            {
243                if ( Character.isLowerCase( character ) )
244                {
245                    lowercase = 1;
246                }
247                else
248                {
249                    if ( Character.isUpperCase( character ) )
250                    {
251                        uppercase = 1;
252                    }
253                    else
254                    {
255                        if ( Character.isDigit( character ) )
256                        {
257                            digit = 1;
258                        }
259                        else
260                        {
261                            if ( !Character.isLetterOrDigit( character ) )
262                            {
263                                nonAlphaNumeric = 1;
264                            }
265                        }
266                    }
267                }
268            }
269    
270            return ( uppercase + lowercase + digit + nonAlphaNumeric ) >= categoryCount;
271        }
272    
273    
274        /**
275         * The password does not contain three letter (or more) tokens from the user's account name.
276         * 
277         * If the account name is less than three characters long, this check is not performed
278         * because the rate at which passwords would be rejected is too high. For each token that is
279         * three or more characters long, that token is searched for in the password; if it is present,
280         * the password change is rejected. For example, the name "First M. Last" would be split into
281         * three tokens: "First", "M", and "Last". Because the second token is only one character long,
282         * it would be ignored. Therefore, this user could not have a password that included either
283         * "first" or "last" as a substring anywhere in the password. All of these checks are
284         * case-insensitive.
285         */
286        boolean isValidUsernameSubstring( String username, String password, int tokenSize )
287        {
288            String[] tokens = username.split( "[^a-zA-Z]" );
289    
290            for ( int ii = 0; ii < tokens.length; ii++ )
291            {
292                if ( tokens[ii].length() >= tokenSize )
293                {
294                    if ( password.matches( "(?i).*" + tokens[ii] + ".*" ) )
295                    {
296                        return false;
297                    }
298                }
299            }
300    
301            return true;
302        }
303    
304    
305        private String buildErrorMessage( String username, String password, int passwordLength, int categoryCount,
306            int tokenSize )
307        {
308            List<String> violations = new ArrayList<String>();
309    
310            if ( !isValidPasswordLength( password, passwordLength ) )
311            {
312                violations.add( "length too short" );
313            }
314    
315            if ( !isValidCategoryCount( password, categoryCount ) )
316            {
317                violations.add( "insufficient character mix" );
318            }
319    
320            if ( !isValidUsernameSubstring( username, password, tokenSize ) )
321            {
322                violations.add( "contains portions of username" );
323            }
324    
325            StringBuffer sb = new StringBuffer( "Password violates policy:  " );
326    
327            boolean isFirst = true;
328    
329            for ( String violation : violations )
330            {
331                if ( isFirst )
332                {
333                    isFirst = false;
334                }
335                else
336                {
337                    sb.append( ", " );
338                }
339    
340                sb.append( violation );
341            }
342    
343            return sb.toString();
344        }
345    }