1 /*
2  * Licensed to the Apache Software Foundation (ASF) under one
3  * or more contributor license agreements.  See the NOTICE file
4  * distributed with this work for additional information
5  * regarding copyright ownership.  The ASF licenses this file
6  * to you under the Apache License, Version 2.0 (the
7  * "License"); you may not use this file except in compliance
8  * with the License.  You may obtain a copy of the License at
9  *
10  *   http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing,
13  * software distributed under the License is distributed on an
14  * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15  * KIND, either express or implied.  See the License for the
16  * specific language governing permissions and limitations
17  * under the License.
18  */
19 
20 package org.apache.guacamole.auth.jdbc.security;
21 
22 import com.google.inject.Inject;
23 import java.util.Arrays;
24 import java.util.List;
25 import java.util.concurrent.TimeUnit;
26 import java.util.regex.Matcher;
27 import java.util.regex.Pattern;
28 import org.apache.guacamole.GuacamoleException;
29 import org.apache.guacamole.auth.jdbc.JDBCEnvironment;
30 import org.apache.guacamole.auth.jdbc.user.ModeledUser;
31 import org.apache.guacamole.auth.jdbc.user.PasswordRecordMapper;
32 import org.apache.guacamole.auth.jdbc.user.PasswordRecordModel;
33 
34 /**
35  * Service which verifies compliance with the password policy configured via
36  * guacamole.properties.
37  */
38 public class PasswordPolicyService {
39 
40     /**
41      * The Guacamole server environment.
42      */
43     @Inject
44     private JDBCEnvironment environment;
45 
46     /**
47      * Mapper for creating/retrieving previously-set passwords.
48      */
49     @Inject
50     private PasswordRecordMapper passwordRecordMapper;
51 
52     /**
53      * Service for hashing passwords.
54      */
55     @Inject
56     private PasswordEncryptionService encryptionService;
57 
58     /**
59      * Regular expression which matches only if the string contains at least one
60      * lowercase character.
61      */
62     private final Pattern CONTAINS_LOWERCASE = Pattern.compile("\\p{javaLowerCase}");
63 
64     /**
65      * Regular expression which matches only if the string contains at least one
66      * uppercase character.
67      */
68     private final Pattern CONTAINS_UPPERCASE = Pattern.compile("\\p{javaUpperCase}");
69 
70     /**
71      * Regular expression which matches only if the string contains at least one
72      * numeric character.
73      */
74     private final Pattern CONTAINS_DIGIT = Pattern.compile("\\p{Digit}");
75 
76     /**
77      * Regular expression which matches only if the string contains at least one
78      * non-alphanumeric character.
79      */
80     private final Pattern CONTAINS_NON_ALPHANUMERIC =
81             Pattern.compile("[^\\p{javaLowerCase}\\p{javaUpperCase}\\p{Digit}]");
82 
83     /**
84      * Returns whether the given string matches all of the provided regular
85      * expressions.
86      *
87      * @param str
88      *     The string to test against all provided regular expressions.
89      *
90      * @param patterns
91      *     The regular expressions to match against the given string.
92      *
93      * @return
94      *     true if the given string matches all provided regular expressions,
95      *     false otherwise.
96      */
matches(String str, Pattern... patterns)97     private boolean matches(String str, Pattern... patterns) {
98 
99         // Check given string against all provided patterns
100         for (Pattern pattern : patterns) {
101 
102             // Fail overall test if any pattern fails to match
103             Matcher matcher = pattern.matcher(str);
104             if (!matcher.find())
105                 return false;
106 
107         }
108 
109         // All provided patterns matched
110         return true;
111 
112     }
113 
114     /**
115      * Returns whether the given password matches any of the user's previous
116      * passwords. Regardless of the value specified here, the maximum number of
117      * passwords involved in this check depends on how many previous passwords
118      * were actually recorded, which depends on the password policy.
119      *
120      * @param password
121      *     The password to check.
122      *
123      * @param username
124      *     The username of the user whose history should be compared against
125      *     the given password.
126      *
127      * @param historySize
128      *     The maximum number of history records to compare the password
129      *     against.
130      *
131      * @return
132      *     true if the given password matches any of the user's previous
133      *     passwords, up to the specified limit, false otherwise.
134      */
matchesPreviousPasswords(String password, String username, int historySize)135     private boolean matchesPreviousPasswords(String password, String username,
136             int historySize) {
137 
138         // No need to compare if no history is relevant
139         if (historySize <= 0)
140             return false;
141 
142         // Check password against all recorded hashes
143         List<PasswordRecordModel> history = passwordRecordMapper.select(username, historySize);
144         for (PasswordRecordModel record : history) {
145 
146             byte[] hash = encryptionService.createPasswordHash(password, record.getPasswordSalt());
147             if (Arrays.equals(hash, record.getPasswordHash()))
148                 return true;
149 
150         }
151 
152         // No passwords match
153         return false;
154 
155     }
156 
157     /**
158      * Verifies that the given new password complies with the password policy
159      * configured within guacamole.properties, throwing a GuacamoleException if
160      * the policy is violated in any way.
161      *
162      * @param username
163      *     The username of the user whose password is being changed.
164      *
165      * @param password
166      *     The proposed new password.
167      *
168      * @throws GuacamoleException
169      *     If the password policy cannot be parsed, or if the proposed password
170      *     violates the password policy.
171      */
verifyPassword(String username, String password)172     public void verifyPassword(String username, String password)
173             throws GuacamoleException {
174 
175         // Retrieve password policy from environment
176         PasswordPolicy policy = environment.getPasswordPolicy();
177 
178         // Enforce minimum password length
179         if (password.length() < policy.getMinimumLength())
180             throw new PasswordMinimumLengthException(
181                     "Password does not meet minimum length requirements.",
182                     policy.getMinimumLength());
183 
184         // Disallow passwords containing the username
185         if (policy.isUsernameProhibited() && password.toLowerCase().contains(username.toLowerCase()))
186             throw new PasswordContainsUsernameException(
187                     "Password must not contain username.");
188 
189         // Require both uppercase and lowercase characters
190         if (policy.isMultipleCaseRequired() && !matches(password, CONTAINS_LOWERCASE, CONTAINS_UPPERCASE))
191             throw new PasswordRequiresMultipleCaseException(
192                     "Password must contain both uppercase and lowercase.");
193 
194         // Require digits
195         if (policy.isNumericRequired() && !matches(password, CONTAINS_DIGIT))
196             throw new PasswordRequiresDigitException(
197                     "Passwords must contain at least one digit.");
198 
199         // Require non-alphanumeric symbols
200         if (policy.isNonAlphanumericRequired() && !matches(password, CONTAINS_NON_ALPHANUMERIC))
201             throw new PasswordRequiresSymbolException(
202                     "Passwords must contain at least one non-alphanumeric character.");
203 
204         // Prohibit password reuse
205         int historySize = policy.getHistorySize();
206         if (matchesPreviousPasswords(password, username, historySize))
207             throw new PasswordReusedException(
208                     "Password matches a previously-used password.", historySize);
209 
210         // Password passes all defined restrictions
211 
212     }
213 
214     /**
215      * Returns the age of the given user's password, in days. The age of a
216      * user's password is the amount of time elapsed since the password was last
217      * changed or reset.
218      *
219      * @param user
220      *     The user to calculate the password age of.
221      *
222      * @return
223      *     The age of the given user's password, in days.
224      */
getPasswordAge(ModeledUser user)225     private long getPasswordAge(ModeledUser user) {
226 
227         // If no password was set, then no time has elapsed
228         PasswordRecordModel passwordRecord = user.getPasswordRecord();
229         if (passwordRecord == null)
230             return 0;
231 
232         // Pull both current time and the time the password was last reset
233         long currentTime = System.currentTimeMillis();
234         long lastResetTime = passwordRecord.getPasswordDate().getTime();
235 
236         // Calculate the number of days elapsed since the password was last reset
237         return TimeUnit.DAYS.convert(currentTime - lastResetTime, TimeUnit.MILLISECONDS);
238 
239     }
240 
241     /**
242      * Verifies that the given user can change their password without violating
243      * password aging policy. If changing the user's password would violate the
244      * aging policy, a GuacamoleException will be thrown.
245      *
246      * @param user
247      *     The user whose password is changing.
248      *
249      * @throws GuacamoleException
250      *     If the user's password cannot be changed due to the password aging
251      *     policy, or of the password policy cannot be parsed from
252      *     guacamole.properties.
253      */
verifyPasswordAge(ModeledUser user)254     public void verifyPasswordAge(ModeledUser user) throws GuacamoleException {
255 
256         // Retrieve password policy from environment
257         PasswordPolicy policy = environment.getPasswordPolicy();
258 
259         long minimumAge = policy.getMinimumAge();
260         long passwordAge = getPasswordAge(user);
261 
262         // Require that sufficient time has elapsed before allowing the password
263         // to be changed
264         if (passwordAge < minimumAge)
265             throw new PasswordTooYoungException("Password was already recently changed.",
266                     minimumAge - passwordAge);
267 
268     }
269 
270     /**
271      * Returns whether the given user's password is expired due to the password
272      * aging policy.
273      *
274      * @param user
275      *     The user to check.
276      *
277      * @return
278      *     true if the user needs to change their password to comply with the
279      *     password aging policy, false otherwise.
280      *
281      * @throws GuacamoleException
282      *     If the password policy cannot be parsed.
283      */
isPasswordExpired(ModeledUser user)284     public boolean isPasswordExpired(ModeledUser user)
285             throws GuacamoleException {
286 
287         // Retrieve password policy from environment
288         PasswordPolicy policy = environment.getPasswordPolicy();
289 
290         // There is no maximum password age if 0
291         int maxPasswordAge = policy.getMaximumAge();
292         if (maxPasswordAge == 0)
293             return false;
294 
295         // Determine whether password is expired based on maximum age
296         return getPasswordAge(user) >= maxPasswordAge;
297 
298     }
299 
300     /**
301      * Records the password that was associated with the given user at the time
302      * the user was queried, such that future attempts to set that same password
303      * for that user will be denied. The number of passwords remembered for each
304      * user is limited by the password policy.
305      *
306      * @param user
307      *     The user whose password should be recorded within the password
308      *     history.
309      *
310      * @throws GuacamoleException
311      *     If the password policy cannot be parsed.
312      */
recordPassword(ModeledUser user)313     public void recordPassword(ModeledUser user)
314             throws GuacamoleException {
315 
316         // Retrieve password policy from environment
317         PasswordPolicy policy = environment.getPasswordPolicy();
318 
319         // Nothing to do if history is not being recorded
320         int historySize = policy.getHistorySize();
321         if (historySize <= 0)
322             return;
323 
324         // Store previous password in history
325         passwordRecordMapper.insert(user.getPasswordRecord(), historySize);
326 
327     }
328 
329 }
330