Classes in this File | Line Coverage | Branch Coverage | Complexity | ||||
JndiLdapRealm |
|
| 2.7857142857142856;2.786 |
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 | package org.apache.shiro.realm.ldap; | |
20 | ||
21 | import org.apache.shiro.authc.AuthenticationException; | |
22 | import org.apache.shiro.authc.AuthenticationInfo; | |
23 | import org.apache.shiro.authc.AuthenticationToken; | |
24 | import org.apache.shiro.authc.SimpleAuthenticationInfo; | |
25 | import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher; | |
26 | import org.apache.shiro.authz.AuthorizationException; | |
27 | import org.apache.shiro.authz.AuthorizationInfo; | |
28 | import org.apache.shiro.ldap.UnsupportedAuthenticationMechanismException; | |
29 | import org.apache.shiro.realm.AuthorizingRealm; | |
30 | import org.apache.shiro.subject.PrincipalCollection; | |
31 | import org.apache.shiro.util.StringUtils; | |
32 | import org.slf4j.Logger; | |
33 | import org.slf4j.LoggerFactory; | |
34 | ||
35 | import javax.naming.AuthenticationNotSupportedException; | |
36 | import javax.naming.NamingException; | |
37 | import javax.naming.ldap.LdapContext; | |
38 | ||
39 | /** | |
40 | * An LDAP {@link org.apache.shiro.realm.Realm Realm} implementation utilizing Sun's/Oracle's | |
41 | * <a href="http://download-llnw.oracle.com/javase/tutorial/jndi/ldap/jndi.html">JNDI API as an LDAP API</a>. This is | |
42 | * Shiro's default implementation for supporting LDAP, as using the JNDI API has been a common approach for Java LDAP | |
43 | * support for many years. | |
44 | * <p/> | |
45 | * This realm implementation and its backing {@link JndiLdapContextFactory} should cover 99% of all Shiro-related LDAP | |
46 | * authentication and authorization needs. However, if it does not suit your needs, you might want to look into | |
47 | * creating your own realm using an alternative, perhaps more robust, LDAP communication API, such as the | |
48 | * <a href="http://directory.apache.org/api/">Apache LDAP API</a>. | |
49 | * <h2>Authentication</h2> | |
50 | * During an authentication attempt, if the submitted {@code AuthenticationToken}'s | |
51 | * {@link org.apache.shiro.authc.AuthenticationToken#getPrincipal() principal} is a simple username, but the | |
52 | * LDAP directory expects a complete User Distinguished Name (User DN) to establish a connection, the | |
53 | * {@link #setUserDnTemplate(String) userDnTemplate} property must be configured. If not configured, | |
54 | * the property will pass the simple username directly as the User DN, which is often incorrect in most LDAP | |
55 | * environments (maybe Microsoft ActiveDirectory being the exception). | |
56 | * <h2>Authorization</h2> | |
57 | * By default, authorization is effectively disabled due to the default | |
58 | * {@link #doGetAuthorizationInfo(org.apache.shiro.subject.PrincipalCollection)} implementation returning {@code null}. | |
59 | * If you wish to perform authorization based on an LDAP schema, you must subclass this one | |
60 | * and override that method to reflect your organization's data model. | |
61 | * <h2>Configuration</h2> | |
62 | * This class primarily provides the {@link #setUserDnTemplate(String) userDnTemplate} property to allow you to specify | |
63 | * the your LDAP server's User DN format. Most other configuration is performed via the nested | |
64 | * {@link LdapContextFactory contextFactory} property. | |
65 | * <p/> | |
66 | * For example, defining this realm in Shiro .ini: | |
67 | * <pre> | |
68 | * [main] | |
69 | * ldapRealm = org.apache.shiro.realm.ldap.JndiLdapRealm | |
70 | * ldapRealm.userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com | |
71 | * ldapRealm.contextFactory.url = ldap://ldapHost:389 | |
72 | * ldapRealm.contextFactory.authenticationMechanism = DIGEST-MD5 | |
73 | * ldapRealm.contextFactory.environment[some.obscure.jndi.key] = some value | |
74 | * ... | |
75 | * </pre> | |
76 | * The default {@link #setContextFactory contextFactory} instance is a {@link JndiLdapContextFactory}. See that | |
77 | * class's JavaDoc for more information on configuring the LDAP connection as well as specifying JNDI environment | |
78 | * properties as necessary. | |
79 | * | |
80 | * @see JndiLdapContextFactory | |
81 | * | |
82 | * @since 1.1 | |
83 | */ | |
84 | public class JndiLdapRealm extends AuthorizingRealm { | |
85 | ||
86 | 2 | private static final Logger log = LoggerFactory.getLogger(JndiLdapRealm.class); |
87 | ||
88 | //The zero index currently means nothing, but could be utilized in the future for other substitution techniques. | |
89 | private static final String USERDN_SUBSTITUTION_TOKEN = "{0}"; | |
90 | ||
91 | private String userDnPrefix; | |
92 | private String userDnSuffix; | |
93 | ||
94 | /*-------------------------------------------- | |
95 | | I N S T A N C E V A R I A B L E S | | |
96 | ============================================*/ | |
97 | /** | |
98 | * The LdapContextFactory instance used to acquire {@link javax.naming.ldap.LdapContext LdapContext}'s at runtime | |
99 | * to acquire connections to the LDAP directory to perform authentication attempts and authorizatino queries. | |
100 | */ | |
101 | private LdapContextFactory contextFactory; | |
102 | ||
103 | /*-------------------------------------------- | |
104 | | C O N S T R U C T O R S | | |
105 | ============================================*/ | |
106 | ||
107 | /** | |
108 | * Default no-argument constructor that defaults the internal {@link LdapContextFactory} instance to a | |
109 | * {@link JndiLdapContextFactory}. | |
110 | */ | |
111 | 26 | public JndiLdapRealm() { |
112 | //Credentials Matching is not necessary - the LDAP directory will do it automatically: | |
113 | 26 | setCredentialsMatcher(new AllowAllCredentialsMatcher()); |
114 | //Any Object principal and Object credentials may be passed to the LDAP provider, so accept any token: | |
115 | 26 | setAuthenticationTokenClass(AuthenticationToken.class); |
116 | 26 | this.contextFactory = new JndiLdapContextFactory(); |
117 | 26 | } |
118 | ||
119 | /*-------------------------------------------- | |
120 | | A C C E S S O R S / M O D I F I E R S | | |
121 | ============================================*/ | |
122 | ||
123 | /** | |
124 | * Returns the User DN prefix to use when building a runtime User DN value or {@code null} if no | |
125 | * {@link #getUserDnTemplate() userDnTemplate} has been configured. If configured, this value is the text that | |
126 | * occurs before the {@link #USERDN_SUBSTITUTION_TOKEN} in the {@link #getUserDnTemplate() userDnTemplate} value. | |
127 | * | |
128 | * @return the the User DN prefix to use when building a runtime User DN value or {@code null} if no | |
129 | * {@link #getUserDnTemplate() userDnTemplate} has been configured. | |
130 | */ | |
131 | protected String getUserDnPrefix() { | |
132 | 8 | return userDnPrefix; |
133 | } | |
134 | ||
135 | /** | |
136 | * Returns the User DN suffix to use when building a runtime User DN value. or {@code null} if no | |
137 | * {@link #getUserDnTemplate() userDnTemplate} has been configured. If configured, this value is the text that | |
138 | * occurs after the {@link #USERDN_SUBSTITUTION_TOKEN} in the {@link #getUserDnTemplate() userDnTemplate} value. | |
139 | * | |
140 | * @return the User DN suffix to use when building a runtime User DN value or {@code null} if no | |
141 | * {@link #getUserDnTemplate() userDnTemplate} has been configured. | |
142 | */ | |
143 | protected String getUserDnSuffix() { | |
144 | 8 | return userDnSuffix; |
145 | } | |
146 | ||
147 | /*-------------------------------------------- | |
148 | | M E T H O D S | | |
149 | ============================================*/ | |
150 | ||
151 | /** | |
152 | * Sets the User Distinguished Name (DN) template to use when creating User DNs at runtime. A User DN is an LDAP | |
153 | * fully-qualified unique user identifier which is required to establish a connection with the LDAP | |
154 | * directory to authenticate users and query for authorization information. | |
155 | * <h2>Usage</h2> | |
156 | * User DN formats are unique to the LDAP directory's schema, and each environment differs - you will need to | |
157 | * specify the format corresponding to your directory. You do this by specifying the full User DN as normal, but | |
158 | * but you use a <b>{@code {0}}</b> placeholder token in the string representing the location where the | |
159 | * user's submitted principal (usually a username or uid) will be substituted at runtime. | |
160 | * <p/> | |
161 | * For example, if your directory | |
162 | * uses an LDAP {@code uid} attribute to represent usernames, the User DN for the {@code jsmith} user may look like | |
163 | * this: | |
164 | * <p/> | |
165 | * <pre>uid=jsmith,ou=users,dc=mycompany,dc=com</pre> | |
166 | * <p/> | |
167 | * in which case you would set this property with the following template value: | |
168 | * <p/> | |
169 | * <pre>uid=<b>{0}</b>,ou=users,dc=mycompany,dc=com</pre> | |
170 | * <p/> | |
171 | * If no template is configured, the raw {@code AuthenticationToken} | |
172 | * {@link AuthenticationToken#getPrincipal() principal} will be used as the LDAP principal. This is likely | |
173 | * incorrect as most LDAP directories expect a fully-qualified User DN as opposed to the raw uid or username. So, | |
174 | * ensure you set this property to match your environment! | |
175 | * | |
176 | * @param template the User Distinguished Name template to use for runtime substitution | |
177 | * @throws IllegalArgumentException if the template is null, empty, or does not contain the | |
178 | * {@code {0}} substitution token. | |
179 | * @see LdapContextFactory#getLdapContext(Object,Object) | |
180 | */ | |
181 | public void setUserDnTemplate(String template) throws IllegalArgumentException { | |
182 | 16 | if (!StringUtils.hasText(template)) { |
183 | 4 | String msg = "User DN template cannot be null or empty."; |
184 | 4 | throw new IllegalArgumentException(msg); |
185 | } | |
186 | 12 | int index = template.indexOf(USERDN_SUBSTITUTION_TOKEN); |
187 | 12 | if (index < 0) { |
188 | 2 | String msg = "User DN template must contain the '" + |
189 | USERDN_SUBSTITUTION_TOKEN + "' replacement token to understand where to " + | |
190 | "insert the runtime authentication principal."; | |
191 | 2 | throw new IllegalArgumentException(msg); |
192 | } | |
193 | 10 | String prefix = template.substring(0, index); |
194 | 10 | String suffix = template.substring(prefix.length() + USERDN_SUBSTITUTION_TOKEN.length()); |
195 | 10 | if (log.isDebugEnabled()) { |
196 | 10 | log.debug("Determined user DN prefix [{}] and suffix [{}]", prefix, suffix); |
197 | } | |
198 | 10 | this.userDnPrefix = prefix; |
199 | 10 | this.userDnSuffix = suffix; |
200 | 10 | } |
201 | ||
202 | /** | |
203 | * Returns the User Distinguished Name (DN) template to use when creating User DNs at runtime - see the | |
204 | * {@link #setUserDnTemplate(String) setUserDnTemplate} JavaDoc for a full explanation. | |
205 | * | |
206 | * @return the User Distinguished Name (DN) template to use when creating User DNs at runtime. | |
207 | */ | |
208 | public String getUserDnTemplate() { | |
209 | 2 | return getUserDn(USERDN_SUBSTITUTION_TOKEN); |
210 | } | |
211 | ||
212 | /** | |
213 | * Returns the LDAP User Distinguished Name (DN) to use when acquiring an | |
214 | * {@link javax.naming.ldap.LdapContext LdapContext} from the {@link LdapContextFactory}. | |
215 | * <p/> | |
216 | * If the the {@link #getUserDnTemplate() userDnTemplate} property has been set, this implementation will construct | |
217 | * the User DN by substituting the specified {@code principal} into the configured template. If the | |
218 | * {@link #getUserDnTemplate() userDnTemplate} has not been set, the method argument will be returned directly | |
219 | * (indicating that the submitted authentication token principal <em>is</em> the User DN). | |
220 | * | |
221 | * @param principal the principal to substitute into the configured {@link #getUserDnTemplate() userDnTemplate}. | |
222 | * @return the constructed User DN to use at runtime when acquiring an {@link javax.naming.ldap.LdapContext}. | |
223 | * @throws IllegalArgumentException if the method argument is null or empty | |
224 | * @throws IllegalStateException if the {@link #getUserDnTemplate userDnTemplate} has not been set. | |
225 | * @see LdapContextFactory#getLdapContext(Object, Object) | |
226 | */ | |
227 | protected String getUserDn(String principal) throws IllegalArgumentException, IllegalStateException { | |
228 | 12 | if (!StringUtils.hasText(principal)) { |
229 | 2 | throw new IllegalArgumentException("User principal cannot be null or empty for User DN construction."); |
230 | } | |
231 | 10 | String prefix = getUserDnPrefix(); |
232 | 10 | String suffix = getUserDnSuffix(); |
233 | 10 | if (prefix == null && suffix == null) { |
234 | 2 | log.debug("userDnTemplate property has not been configured, indicating the submitted " + |
235 | "AuthenticationToken's principal is the same as the User DN. Returning the method argument " + | |
236 | "as is."); | |
237 | 2 | return principal; |
238 | } | |
239 | ||
240 | 8 | int prefixLength = prefix != null ? prefix.length() : 0; |
241 | 8 | int suffixLength = suffix != null ? suffix.length() : 0; |
242 | 8 | StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength); |
243 | 8 | if (prefixLength > 0) { |
244 | 8 | sb.append(prefix); |
245 | } | |
246 | 8 | sb.append(principal); |
247 | 8 | if (suffixLength > 0) { |
248 | 8 | sb.append(suffix); |
249 | } | |
250 | 8 | return sb.toString(); |
251 | } | |
252 | ||
253 | /** | |
254 | * Sets the LdapContextFactory instance used to acquire connections to the LDAP directory during authentication | |
255 | * attempts and authorization queries. Unless specified otherwise, the default is a {@link JndiLdapContextFactory} | |
256 | * instance. | |
257 | * | |
258 | * @param contextFactory the LdapContextFactory instance used to acquire connections to the LDAP directory during | |
259 | * authentication attempts and authorization queries | |
260 | */ | |
261 | @SuppressWarnings({"UnusedDeclaration"}) | |
262 | public void setContextFactory(LdapContextFactory contextFactory) { | |
263 | 8 | this.contextFactory = contextFactory; |
264 | 8 | } |
265 | ||
266 | /** | |
267 | * Returns the LdapContextFactory instance used to acquire connections to the LDAP directory during authentication | |
268 | * attempts and authorization queries. Unless specified otherwise, the default is a {@link JndiLdapContextFactory} | |
269 | * instance. | |
270 | * | |
271 | * @return the LdapContextFactory instance used to acquire connections to the LDAP directory during | |
272 | * authentication attempts and authorization queries | |
273 | */ | |
274 | public LdapContextFactory getContextFactory() { | |
275 | 28 | return this.contextFactory; |
276 | } | |
277 | ||
278 | /*-------------------------------------------- | |
279 | | M E T H O D S | | |
280 | ============================================*/ | |
281 | ||
282 | /** | |
283 | * Delegates to {@link #queryForAuthenticationInfo(org.apache.shiro.authc.AuthenticationToken, LdapContextFactory)}, | |
284 | * wrapping any {@link NamingException}s in a Shiro {@link AuthenticationException} to satisfy the parent method | |
285 | * signature. | |
286 | * | |
287 | * @param token the authentication token containing the user's principal and credentials. | |
288 | * @return the {@link AuthenticationInfo} acquired after a successful authentication attempt | |
289 | * @throws AuthenticationException if the authentication attempt fails or if a | |
290 | * {@link NamingException} occurs. | |
291 | */ | |
292 | protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { | |
293 | AuthenticationInfo info; | |
294 | try { | |
295 | 8 | info = queryForAuthenticationInfo(token, getContextFactory()); |
296 | 0 | } catch (AuthenticationNotSupportedException e) { |
297 | 0 | String msg = "Unsupported configured authentication mechanism"; |
298 | 0 | throw new UnsupportedAuthenticationMechanismException(msg, e); |
299 | 2 | } catch (javax.naming.AuthenticationException e) { |
300 | 2 | throw new AuthenticationException("LDAP authentication failed.", e); |
301 | 2 | } catch (NamingException e) { |
302 | 2 | String msg = "LDAP naming error while attempting to authenticate user."; |
303 | 2 | throw new AuthenticationException(msg, e); |
304 | 4 | } |
305 | ||
306 | 4 | return info; |
307 | } | |
308 | ||
309 | ||
310 | protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { | |
311 | AuthorizationInfo info; | |
312 | try { | |
313 | 0 | info = queryForAuthorizationInfo(principals, getContextFactory()); |
314 | 0 | } catch (NamingException e) { |
315 | 0 | String msg = "LDAP naming error while attempting to retrieve authorization for user [" + principals + "]."; |
316 | 0 | throw new AuthorizationException(msg, e); |
317 | 0 | } |
318 | ||
319 | 0 | return info; |
320 | } | |
321 | ||
322 | /** | |
323 | * Returns the principal to use when creating the LDAP connection for an authentication attempt. | |
324 | * <p/> | |
325 | * This implementation uses a heuristic: it checks to see if the specified token's | |
326 | * {@link AuthenticationToken#getPrincipal() principal} is a {@code String}, and if so, | |
327 | * {@link #getUserDn(String) converts it} from what is | |
328 | * assumed to be a raw uid or username {@code String} into a User DN {@code String}. Almost all LDAP directories | |
329 | * expect the authentication connection to present a User DN and not an unqualified username or uid. | |
330 | * <p/> | |
331 | * If the token's {@code principal} is not a String, it is assumed to already be in the format supported by the | |
332 | * underlying {@link LdapContextFactory} implementation and the raw principal is returned directly. | |
333 | * | |
334 | * @param token the {@link AuthenticationToken} submitted during the authentication process | |
335 | * @return the User DN or raw principal to use to acquire the LdapContext. | |
336 | * @see LdapContextFactory#getLdapContext(Object, Object) | |
337 | */ | |
338 | protected Object getLdapPrincipal(AuthenticationToken token) { | |
339 | 8 | Object principal = token.getPrincipal(); |
340 | 8 | if (principal instanceof String) { |
341 | 6 | String sPrincipal = (String) principal; |
342 | 6 | return getUserDn(sPrincipal); |
343 | } | |
344 | 2 | return principal; |
345 | } | |
346 | ||
347 | /** | |
348 | * This implementation opens an LDAP connection using the token's | |
349 | * {@link #getLdapPrincipal(org.apache.shiro.authc.AuthenticationToken) discovered principal} and provided | |
350 | * {@link AuthenticationToken#getCredentials() credentials}. If the connection opens successfully, the | |
351 | * authentication attempt is immediately considered successful and a new | |
352 | * {@link AuthenticationInfo} instance is | |
353 | * {@link #createAuthenticationInfo(org.apache.shiro.authc.AuthenticationToken, Object, Object, javax.naming.ldap.LdapContext) created} | |
354 | * and returned. If the connection cannot be opened, either because LDAP authentication failed or some other | |
355 | * JNDI problem, an {@link NamingException} will be thrown. | |
356 | * | |
357 | * @param token the submitted authentication token that triggered the authentication attempt. | |
358 | * @param ldapContextFactory factory used to retrieve LDAP connections. | |
359 | * @return an {@link AuthenticationInfo} instance representing the authenticated user's information. | |
360 | * @throws NamingException if any LDAP errors occur. | |
361 | */ | |
362 | protected AuthenticationInfo queryForAuthenticationInfo(AuthenticationToken token, | |
363 | LdapContextFactory ldapContextFactory) | |
364 | throws NamingException { | |
365 | ||
366 | 8 | Object principal = token.getPrincipal(); |
367 | 8 | Object credentials = token.getCredentials(); |
368 | ||
369 | 8 | log.debug("Authenticating user '{}' through LDAP", principal); |
370 | ||
371 | 8 | principal = getLdapPrincipal(token); |
372 | ||
373 | 8 | LdapContext ctx = null; |
374 | try { | |
375 | 8 | ctx = ldapContextFactory.getLdapContext(principal, credentials); |
376 | //context was opened successfully, which means their credentials were valid. Return the AuthenticationInfo: | |
377 | 8 | return createAuthenticationInfo(token, principal, credentials, ctx); |
378 | } finally { | |
379 | 8 | LdapUtils.closeContext(ctx); |
380 | } | |
381 | } | |
382 | ||
383 | /** | |
384 | * Returns the {@link AuthenticationInfo} resulting from a Subject's successful LDAP authentication attempt. | |
385 | * <p/> | |
386 | * This implementation ignores the {@code ldapPrincipal}, {@code ldapCredentials}, and the opened | |
387 | * {@code ldapContext} arguments and merely returns an {@code AuthenticationInfo} instance mirroring the | |
388 | * submitted token's principal and credentials. This is acceptable because this method is only ever invoked after | |
389 | * a successful authentication attempt, which means the provided principal and credentials were correct, and can | |
390 | * be used directly to populate the (now verified) {@code AuthenticationInfo}. | |
391 | * <p/> | |
392 | * Subclasses however are free to override this method for more advanced construction logic. | |
393 | * | |
394 | * @param token the submitted {@code AuthenticationToken} that resulted in a successful authentication | |
395 | * @param ldapPrincipal the LDAP principal used when creating the LDAP connection. Unlike the token's | |
396 | * {@link AuthenticationToken#getPrincipal() principal}, this value is usually a constructed | |
397 | * User DN and not a simple username or uid. The exact value is depending on the | |
398 | * configured | |
399 | * <a href="http://download-llnw.oracle.com/javase/tutorial/jndi/ldap/auth_mechs.html"> | |
400 | * LDAP authentication mechanism</a> in use. | |
401 | * @param ldapCredentials the LDAP credentials used when creating the LDAP connection. | |
402 | * @param ldapContext the LdapContext created that resulted in a successful authentication. It can be used | |
403 | * further by subclasses for more complex operations. It does not need to be closed - | |
404 | * it will be closed automatically after this method returns. | |
405 | * @return the {@link AuthenticationInfo} resulting from a Subject's successful LDAP authentication attempt. | |
406 | * @throws NamingException if there was any problem using the {@code LdapContext} | |
407 | */ | |
408 | @SuppressWarnings({"UnusedDeclaration"}) | |
409 | protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, Object ldapPrincipal, | |
410 | Object ldapCredentials, LdapContext ldapContext) | |
411 | throws NamingException { | |
412 | 4 | return new SimpleAuthenticationInfo(token.getPrincipal(), token.getCredentials(), getName()); |
413 | } | |
414 | ||
415 | ||
416 | /** | |
417 | * Method that should be implemented by subclasses to build an | |
418 | * {@link AuthorizationInfo} object by querying the LDAP context for the | |
419 | * specified principal.</p> | |
420 | * | |
421 | * @param principals the principals of the Subject whose AuthenticationInfo should be queried from the LDAP server. | |
422 | * @param ldapContextFactory factory used to retrieve LDAP connections. | |
423 | * @return an {@link AuthorizationInfo} instance containing information retrieved from the LDAP server. | |
424 | * @throws NamingException if any LDAP errors occur during the search. | |
425 | */ | |
426 | protected AuthorizationInfo queryForAuthorizationInfo(PrincipalCollection principals, | |
427 | LdapContextFactory ldapContextFactory) throws NamingException { | |
428 | 0 | return null; |
429 | } | |
430 | } |