001package com.pingidentity.ds.passwordvalidator;
002
003import com.unboundid.directory.sdk.common.types.Entry;
004import com.unboundid.directory.sdk.common.types.OperationContext;
005import com.unboundid.directory.sdk.ds.api.PasswordValidator;
006import com.unboundid.directory.sdk.ds.config.PasswordValidatorConfig;
007import com.unboundid.directory.sdk.ds.types.DirectoryServerContext;
008import com.unboundid.ldap.sdk.LDAPException;
009import com.unboundid.ldap.sdk.ResultCode;
010import com.unboundid.ldap.sdk.unboundidds.extensions.PasswordQualityRequirement;
011import com.unboundid.util.ByteString;
012import com.unboundid.util.StaticUtils;
013import com.unboundid.util.args.*;
014
015import java.util.ArrayList;
016import java.util.List;
017import java.util.Set;
018import java.util.concurrent.atomic.AtomicInteger;
019
020/**
021 * This class provides functionality to enforce requiring N character sets out of M defined
022 */
023public class NOutOfMCharacterSets extends PasswordValidator
024{
025    private static final String ARG_CHARACTER_SET = "character-set";
026    private static final String ARG_NUM_REQUIRED_SETS = "minimum-sets";
027    private static final String FIELD_SEPARATOR = ":";
028    private Integer numberRequiredSets;
029    private List<CharacterSetConstraint> characterSets;
030    
031    /**
032     * @return a string with the extension name
033     */
034    @Override
035    public String getExtensionName()
036    {
037        return "ds-password-validator-n-out-of-m-character-set";
038    }
039    
040    /**
041     * @return an array of strings with detailed description of the extension, each element in the array is displayed
042     * as a paragraph in the manage-extension CLI
043     */
044    @Override
045    public String[] getExtensionDescription()
046    {
047        return new String[]{"Validates password characters with a configurable number of character sets"};
048    }
049    
050    @Override
051    public PasswordQualityRequirement getPasswordQualityRequirement()
052    {
053        return null;
054    }
055    
056    /**
057     * This method applies configuration to the extension instance
058     *
059     * @param config               the configuration object for this extension
060     * @param parser               the argument parser
061     * @param adminActionsRequired a list of messages describing administrative actions that applying configuration
062     *                             will require
063     * @param messages             a list of messages about the applying this configuration
064     * @return ResultCode SUCCESS if applying configuration succeeded
065     */
066    @Override
067    public ResultCode applyConfiguration(PasswordValidatorConfig config, ArgumentParser parser, List<String>
068            adminActionsRequired, List<String> messages)
069    {
070        characterSets = new ArrayList<>();
071        for (String s : parser.getStringArgument(ARG_CHARACTER_SET).getValues())
072        {
073            characterSets.add(new CharacterSetConstraint(s));
074        }
075        numberRequiredSets = parser.getIntegerArgument(ARG_NUM_REQUIRED_SETS).getValue();
076        return ResultCode.SUCCESS;
077    }
078    
079    /**
080     * This method performs the necessary processing to ensure the configuration is correct
081     *
082     * @param config              the extension configuration
083     * @param parser              the argument parser
084     * @param unacceptableReasons list of unacceptable reasons
085     * @return true if the configuration is acceptable
086     */
087    @Override
088    public boolean isConfigurationAcceptable(PasswordValidatorConfig config, ArgumentParser parser, List<String>
089            unacceptableReasons)
090    {
091        return true;
092    }
093    
094    /**
095     * Performs all necessary processing to initialize the extension
096     *
097     * @param serverContext the server context
098     * @param config        the extension instance configuration object
099     * @param parser        the argument parser for this extension instance
100     * @throws LDAPException in case processing encountered an issue
101     */
102    @Override
103    public void initializePasswordValidator(DirectoryServerContext serverContext, PasswordValidatorConfig config,
104                                            ArgumentParser parser) throws LDAPException
105    {
106        // Use the applyConfiguration method to set up the plugin.
107        final ArrayList<String> adminActionsRequired = new ArrayList<>(5);
108        final ArrayList<String> messages = new ArrayList<>(5);
109        final ResultCode resultCode = applyConfiguration(config, parser, adminActionsRequired, messages);
110        if (resultCode != ResultCode.SUCCESS)
111        {
112            throw new LDAPException(resultCode,
113                    "One or more errors occurred while trying to initialize  " + getExtensionName() + " extension  in '"
114                            + config.getConfigObjectDN() + ":  " +
115                            StaticUtils.concatenateStrings(messages));
116        }
117    }
118    
119    /**
120     * Performs all the necessary processing to define the configuration arguments this extension needs
121     *
122     * @param parser the argument parser
123     * @throws ArgumentException when the argument declaration fails
124     */
125    @Override
126    public void defineConfigArguments(ArgumentParser parser) throws ArgumentException
127    {
128        StringArgument characterSetArgument = new StringArgument(null, ARG_CHARACTER_SET, true, 0, "{set}",
129                "Specifies a character set " +
130                        "containing characters that a password may contain and a value indicating the minimum number " +
131                        "of " +
132                        "characters required from that set. Each value must be an integer (indicating the minimum " +
133                        "required " +
134                        "characters from the set) followed by a colon and the characters to include in that set (for " +
135                        "example," +
136                        " \"3:abcdefghijklmnopqrstuvwxyz\" indicates that a user password must contain at least three" +
137                        " " +
138                        "characters from the set of lowercase ASCII letters). Multiple character sets can be defined " +
139                        "in " +
140                        "separate values, although no character can appear in more than one character set. Syntax: " +
141                        "STRING");
142        characterSetArgument.addValueValidator(new ArgumentValueValidator()
143        {
144            @Override
145            public void validateArgumentValue(Argument argument, String s) throws ArgumentException
146            {
147                String messageAboutFormat = "The value must be an integer (indicating the minimum required characters" +
148                        " from the set) followed by a colon and the characters to include in that set";
149                if (!s.contains(FIELD_SEPARATOR))
150                {
151                    throw new ArgumentException("The value is missing a colon. " + messageAboutFormat);
152                }
153                String[] argumentValues = s.split(FIELD_SEPARATOR);
154                if (argumentValues.length != 2)
155                {
156                    throw new ArgumentException("The value must have exactly 2 fields separated by one " +
157                            FIELD_SEPARATOR + " character. " + messageAboutFormat);
158                }
159                try
160                {
161                    Integer.parseInt(argumentValues[0]);
162                } catch (NumberFormatException nfe)
163                {
164                    throw new ArgumentException("The first field is not an integer. " + messageAboutFormat);
165                }
166                if (argumentValues[1] == null || argumentValues[1].isEmpty())
167                {
168                    throw new ArgumentException("The second field must contain at least one character." +
169                            messageAboutFormat);
170                }
171            }
172        });
173        
174        parser.addArgument(characterSetArgument);
175        parser.addArgument(new IntegerArgument(null, ARG_NUM_REQUIRED_SETS, false, 1, "{required-sets}", "Minimum " +
176                "number of sets the proposed password must match", 0, 99));
177    }
178    
179    /**
180     * This method performs all the necessary processing to evaluate if a password is acceptable for this extension
181     * instance
182     *
183     * @param operationContext the operation context
184     * @param newPassword      the nre password to evaluate
185     * @param currentPasswords the list of current passwords
186     * @param entry            the user entry
187     * @param invalidReason    a description of why the provided new password is not acceptable
188     * @return true if the password is acceptable
189     */
190    @Override
191    public boolean isPasswordAcceptable(OperationContext operationContext, ByteString newPassword, Set<ByteString>
192            currentPasswords, Entry entry, StringBuilder invalidReason)
193    {
194        if (invalidReason == null)
195        {
196            invalidReason = new StringBuilder();
197        }
198        // no point testing this password if the number of characters is less than the number of classes to match
199        if (newPassword.getValue().length < numberRequiredSets)
200        {
201            invalidReason.append("The provided password is too short to possibly match the required " +
202                    numberRequiredSets + " character set" + (numberRequiredSets > 1 ? "s" : "") + ".");
203            return false;
204        }
205        
206        // initialize the number of required sets
207        //Integer remainingSetsToMatch = numberRequiredSets;
208        
209        // initialize the counter for each set
210        List<CharacterSetConstraint> remainingSets = new ArrayList<>();
211        for (CharacterSetConstraint set : characterSets)
212        {
213            set.reset();
214            remainingSets.add(set);
215        }
216        
217        for (byte b : newPassword.getValue())
218        {
219            for (CharacterSetConstraint set : remainingSets)
220            {
221                if (set.contains(b))
222                {
223                    if (set.isSatisfied())
224                    {
225                        remainingSets.remove(set);
226                    }
227                    if ((characterSets.size() - remainingSets.size()) >= numberRequiredSets)
228                    {
229                        return true;
230                    }
231                    // if found in one set, move on to next byte, no point iterating over other sets
232                    break;
233                }
234            }
235        }
236        
237        int matchedSets = characterSets.size() - remainingSets.size();
238        invalidReason.append("The provided password only matched " + matchedSets + " " +
239                "character set"+(matchedSets>1?"s":"")+".");
240        invalidReason.append("Retry with a password that includes "+ numberRequiredSets+" of the following: \n");
241        for (CharacterSetConstraint setConstraint : characterSets )
242        {
243            invalidReason.append(setConstraint.description()+"\n");
244        }
245        return false;
246    }
247    
248    /**
249     * This class represents a character set constraint with the number of required characters for each character set
250     */
251    class CharacterSetConstraint
252    {
253        private Integer requiredHits;
254        private byte[] characters;
255        private byte min = Byte.MAX_VALUE;
256        private byte max = Byte.MIN_VALUE;
257        private ThreadLocal<AtomicInteger> counter;
258        
259        CharacterSetConstraint(String s)
260        {
261            String[] fields = s.split(FIELD_SEPARATOR);
262            requiredHits = Integer.parseInt(fields[0]);
263            characters = fields[1].getBytes();
264            // TODO: de-depulicate characters (it is supposed to be a set after all)
265            // TODO: order characters to be able to detect if it is a contiguous set
266            for (byte b : characters)
267            {
268                if (b < min)
269                {
270                    min = b;
271                }
272                if (b > max)
273                {
274                    max = b;
275                }
276            }
277            counter = new ThreadLocal<>();
278            reset();
279        }
280        
281        /**
282         * set the counter to the required number of requiredHits
283         */
284        void reset()
285        {
286            counter.set(new AtomicInteger(requiredHits));
287        }
288        
289        /**
290         * This method performs the necessary processing to evaluate if a byte satisfies the character set constraint
291         *
292         * @param b the byte to evaluate
293         * @return true if the class was already satisfied or if the provided byte satisfies the class
294         */
295        boolean contains(byte b)
296        {
297            // slight optimization to rule out obvious case
298            if (min <= b && b <= max)
299            {
300                for (byte a : characters)
301                {
302                    if (a == b)
303                    {
304                        if (counter.get().get() > 0 && counter.get().decrementAndGet() == 0)
305                        {
306                            return true;
307                        }
308                    }
309                }
310            }
311            return false;
312        }
313        
314        boolean isSatisfied()
315        {
316            return (counter.get().get() <= 0);
317        }
318        
319        String description()
320        {
321            return requiredHits+ " of "+new String(characters);
322        }
323    }
324}