-
Notifications
You must be signed in to change notification settings - Fork 5.8k
/
ActiveDirectoryLdapAuthenticationProvider.java
406 lines (358 loc) · 15.7 KB
/
ActiveDirectoryLdapAuthenticationProvider.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.ldap.authentication.ad;
import java.io.Serializable;
import java.util.Collection;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.AuthenticationException;
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.OperationNotSupportedException;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.ldap.InitialLdapContext;
import org.springframework.core.log.LogMessage;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.ldap.CommunicationException;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.ldap.core.support.DefaultDirObjectFactory;
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.InternalAuthenticationServiceException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Specialized LDAP authentication provider which uses Active Directory configuration
* conventions.
* <p>
* It will authenticate using the Active Directory
* <a href="https://msdn.microsoft.com/en-us/library/ms680857%28VS.85%29.aspx">
* {@code userPrincipalName}</a> or a custom {@link #setSearchFilter(String) searchFilter}
* 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.
* <p>
* <h3>Active Directory Sub-Error Codes</h3>
* <p>
* 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>
* <p>
* 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
* @author Rob Winch
* @author Roman Zabaluev
* @since 3.1
*/
public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLdapAuthenticationProvider {
private static final Pattern SUB_ERROR_CODE = Pattern.compile(".*data\\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 convertSubErrorCodesToExceptions;
private String searchFilter = "(&(objectClass=user)(userPrincipalName={0}))";
private Map<String, Object> contextEnvironmentProperties = new HashMap<>();
// Only used to allow tests to substitute a mock LdapContext
ContextFactory contextFactory = new ContextFactory();
private LdapAuthoritiesPopulator authoritiesPopulator = new DefaultActiveDirectoryAuthoritiesPopulator();
/**
* @param domain the domain name (can be null or empty)
* @param url an LDAP url (or multiple URLs)
* @param rootDn the root DN (can be null or empty)
*/
public ActiveDirectoryLdapAuthenticationProvider(String domain, String url, String rootDn) {
Assert.isTrue(StringUtils.hasText(url), "Url cannot be empty");
this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
this.url = url;
this.rootDn = StringUtils.hasText(rootDn) ? rootDn.toLowerCase() : null;
}
/**
* @param domain the domain name (can be null or empty)
* @param url an LDAP url (or multiple URLs)
*/
public ActiveDirectoryLdapAuthenticationProvider(String domain, String url) {
Assert.isTrue(StringUtils.hasText(url), "Url cannot be empty");
this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
this.url = url;
this.rootDn = (this.domain != null) ? rootDnFromDomain(this.domain) : null;
}
@Override
protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken auth) {
String username = auth.getName();
String password = (String) auth.getCredentials();
DirContext ctx = null;
try {
ctx = bindAsUser(username, password);
return searchForUser(ctx, username);
}
catch (CommunicationException ex) {
throw badLdapConnection(ex);
}
catch (NamingException ex) {
this.logger.error("Failed to locate directory entry for authenticated user: " + username, ex);
throw badCredentials(ex);
}
finally {
LdapUtils.closeContext(ctx);
}
}
/**
* Creates the user authority list from the values of the {@code memberOf} attribute
* obtained from the user's Active Directory entry.
*/
@Override
protected Collection<? extends GrantedAuthority> loadUserAuthorities(DirContextOperations userData, String username,
String password) {
return this.authoritiesPopulator.getGrantedAuthorities(userData, username);
}
private DirContext bindAsUser(String username, String password) {
// TODO. add DNS lookup based on domain
final String bindUrl = this.url;
Hashtable<String, Object> env = new Hashtable<>();
env.put(Context.SECURITY_AUTHENTICATION, "simple");
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");
env.put(Context.OBJECT_FACTORIES, DefaultDirObjectFactory.class.getName());
env.putAll(this.contextEnvironmentProperties);
try {
return this.contextFactory.createContext(env);
}
catch (NamingException ex) {
if ((ex instanceof AuthenticationException) || (ex instanceof OperationNotSupportedException)) {
handleBindException(bindPrincipal, ex);
throw badCredentials(ex);
}
throw LdapUtils.convertLdapException(ex);
}
}
private void handleBindException(String bindPrincipal, NamingException exception) {
this.logger.debug(LogMessage.format("Authentication for %s failed:%s", bindPrincipal, exception));
handleResolveObj(exception);
int subErrorCode = parseSubErrorCode(exception.getMessage());
if (subErrorCode <= 0) {
this.logger.debug("Failed to locate AD-specific sub-error code in message");
return;
}
this.logger
.info(LogMessage.of(() -> "Active Directory authentication failed: " + subCodeToLogMessage(subErrorCode)));
if (this.convertSubErrorCodesToExceptions) {
raiseExceptionForErrorCode(subErrorCode, exception);
}
}
private void handleResolveObj(NamingException exception) {
Object resolvedObj = exception.getResolvedObj();
boolean serializable = resolvedObj instanceof Serializable;
if (resolvedObj != null && !serializable) {
exception.setResolvedObj(null);
}
}
private int parseSubErrorCode(String message) {
Matcher matcher = SUB_ERROR_CODE.matcher(message);
if (matcher.matches()) {
return Integer.parseInt(matcher.group(1), 16);
}
return -1;
}
private void raiseExceptionForErrorCode(int code, NamingException exception) {
String hexString = Integer.toHexString(code);
Throwable cause = new ActiveDirectoryAuthenticationException(hexString, exception.getMessage(), exception);
switch (code) {
case PASSWORD_EXPIRED -> throw new CredentialsExpiredException(this.messages
.getMessage("LdapAuthenticationProvider.credentialsExpired", "User credentials have expired"), cause);
case ACCOUNT_DISABLED -> throw new DisabledException(
this.messages.getMessage("LdapAuthenticationProvider.disabled", "User is disabled"), cause);
case ACCOUNT_EXPIRED -> throw new AccountExpiredException(
this.messages.getMessage("LdapAuthenticationProvider.expired", "User account has expired"), cause);
case ACCOUNT_LOCKED -> throw new LockedException(
this.messages.getMessage("LdapAuthenticationProvider.locked", "User account is locked"), cause);
default -> throw badCredentials(cause);
}
}
private String subCodeToLogMessage(int code) {
return switch (code) {
case USERNAME_NOT_FOUND -> "User was not found in directory";
case INVALID_PASSWORD -> "Supplied password was invalid";
case NOT_PERMITTED -> "User not permitted to logon at this time";
case PASSWORD_EXPIRED -> "Password has expired";
case ACCOUNT_DISABLED -> "Account is disabled";
case ACCOUNT_EXPIRED -> "Account expired";
case PASSWORD_NEEDS_RESET -> "User must reset password";
case ACCOUNT_LOCKED -> "Account locked";
default -> "Unknown (error code " + Integer.toHexString(code) + ")";
};
}
private BadCredentialsException badCredentials() {
return new BadCredentialsException(
this.messages.getMessage("LdapAuthenticationProvider.badCredentials", "Bad credentials"));
}
private BadCredentialsException badCredentials(Throwable cause) {
return (BadCredentialsException) badCredentials().initCause(cause);
}
private InternalAuthenticationServiceException badLdapConnection(Throwable cause) {
return new InternalAuthenticationServiceException(this.messages
.getMessage("LdapAuthenticationProvider.badLdapConnection", "Connection to LDAP server failed."), cause);
}
private DirContextOperations searchForUser(DirContext context, String username) throws NamingException {
SearchControls searchControls = new SearchControls();
searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
String bindPrincipal = createBindPrincipal(username);
String searchRoot = (this.rootDn != null) ? this.rootDn : searchRootFromPrincipal(bindPrincipal);
try {
return SpringSecurityLdapTemplate.searchForSingleEntryInternal(context, searchControls, searchRoot,
this.searchFilter, new Object[] { bindPrincipal, username });
}
catch (CommunicationException ex) {
throw badLdapConnection(ex);
}
catch (IncorrectResultSizeDataAccessException ex) {
// Search should never return multiple results if properly configured -
if (ex.getActualSize() != 0) {
throw ex;
}
// If we found no results, then the username/password did not match
UsernameNotFoundException userNameNotFoundException = new UsernameNotFoundException(
"User " + username + " not found in directory.", ex);
throw badCredentials(userNameNotFoundException);
}
}
private String searchRootFromPrincipal(String bindPrincipal) {
int atChar = bindPrincipal.lastIndexOf('@');
if (atChar < 0) {
this.logger.debug("User principal '" + bindPrincipal
+ "' does not contain the domain, and no domain has been configured");
throw badCredentials();
}
return rootDnFromDomain(bindPrincipal.substring(atChar + 1));
}
private String rootDnFromDomain(String domain) {
String[] tokens = StringUtils.tokenizeToStringArray(domain, ".");
StringBuilder root = new StringBuilder();
for (String token : tokens) {
if (!root.isEmpty()) {
root.append(',');
}
root.append("dc=").append(token);
}
return root.toString();
}
String createBindPrincipal(String username) {
if (this.domain == null || username.toLowerCase().endsWith(this.domain)) {
return username;
}
return username + "@" + this.domain;
}
/**
* 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;
}
/**
* The LDAP filter string to search for the user being authenticated. Occurrences of
* {0} are replaced with the {@code username@domain}. Occurrences of {1} are replaced
* with the {@code username} only.
* <p>
* Defaults to: {@code (&(objectClass=user)(userPrincipalName={0}))}
* </p>
* @param searchFilter the filter string
* @since 3.2.6
*/
public void setSearchFilter(String searchFilter) {
Assert.hasText(searchFilter, "searchFilter must have text");
this.searchFilter = searchFilter;
}
/**
* Allows a custom environment properties to be used to create initial LDAP context.
* @param environment the additional environment parameters to use when creating the
* LDAP Context
*/
public void setContextEnvironmentProperties(Map<String, Object> environment) {
Assert.notEmpty(environment, "environment must not be empty");
this.contextEnvironmentProperties = new Hashtable<>(environment);
}
/**
* Set the strategy for obtaining the authorities for a given user after they've been
* authenticated. Consider adjusting this if you require a custom authorities mapping
* algorithm different from a default one. The default value is
* DefaultActiveDirectoryAuthoritiesPopulator.
* @param authoritiesPopulator authorities population strategy
* @since 6.3
*/
public void setAuthoritiesPopulator(LdapAuthoritiesPopulator authoritiesPopulator) {
Assert.notNull(authoritiesPopulator, "authoritiesPopulator must not be null");
this.authoritiesPopulator = authoritiesPopulator;
}
static class ContextFactory {
DirContext createContext(Hashtable<?, ?> env) throws NamingException {
return new InitialLdapContext(env, null);
}
}
}