1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.auth.shiro.realm;
18  
19  import static com.google.common.base.Preconditions.checkArgument;
20  import static java.util.Objects.requireNonNull;
21  
22  import java.time.Duration;
23  import java.util.regex.Pattern;
24  
25  import javax.annotation.Nullable;
26  import javax.naming.AuthenticationException;
27  import javax.naming.NamingEnumeration;
28  import javax.naming.NamingException;
29  import javax.naming.ServiceUnavailableException;
30  import javax.naming.directory.SearchControls;
31  import javax.naming.directory.SearchResult;
32  import javax.naming.ldap.LdapContext;
33  
34  import org.apache.shiro.authc.AuthenticationInfo;
35  import org.apache.shiro.authc.AuthenticationToken;
36  import org.apache.shiro.authc.UsernamePasswordToken;
37  import org.apache.shiro.realm.Realm;
38  import org.apache.shiro.realm.activedirectory.ActiveDirectoryRealm;
39  import org.apache.shiro.realm.ldap.LdapContextFactory;
40  import org.apache.shiro.realm.ldap.LdapUtils;
41  
42  /**
43   * A variant of {@link ActiveDirectoryRealm} that binds first with the privileged credential to search for
44   * the DN of a user from a username before the actual authentication. This {@link Realm} is useful when
45   * there is no simple rule to convert a username into a DN.
46   *
47   * <p>The INI configuration might be specified as follows:
48   * <pre>{@code
49   * [main]
50   * adRealm = com.linecorp.centraldogma.server.support.shiro.SearchFirstActiveDirectoryRealm
51   * adRealm.url = ldap://hostname:port
52   * adRealm.systemUsername = admin
53   * adRealm.systemPassword = admin
54   * adRealm.searchBase = ...
55   * adRealm.searchFilter = cn={0}
56   * adRealm.searchTimeoutMillis = 10000
57   * }</pre>
58   */
59  public class SearchFirstActiveDirectoryRealm extends ActiveDirectoryRealm {
60  
61      private static final Pattern USERNAME_PLACEHOLDER = Pattern.compile("\\{0}");
62      private static final String DEFAULT_SEARCH_FILTER = "cn={0}";
63      private static final int DEFAULT_SEARCH_TIMEOUT_MILLIS = (int) Duration.ofSeconds(10).toMillis();
64  
65      @Nullable
66      private String searchFilter = DEFAULT_SEARCH_FILTER;
67      private int searchTimeoutMillis = DEFAULT_SEARCH_TIMEOUT_MILLIS;
68  
69      /**
70       * Returns a search filter string.
71       */
72      @Nullable
73      protected String getSearchFilter() {
74          return searchFilter;
75      }
76  
77      /**
78       * Sets a search filter string.
79       */
80      protected void setSearchFilter(String searchFilter) {
81          this.searchFilter = requireNonNull(searchFilter, "searchFilter");
82      }
83  
84      /**
85       * Returns a timeout(ms) for LDAP search.
86       */
87      public int getSearchTimeoutMillis() {
88          return searchTimeoutMillis;
89      }
90  
91      /**
92       * Sets a timeout(ms) for LDAP search.
93       */
94      protected void setSearchTimeoutMillis(int searchTimeoutMillis) {
95          checkArgument(searchTimeoutMillis >= 0,
96                        "searchTimeoutMillis should be 0 or positive number");
97          this.searchTimeoutMillis = searchTimeoutMillis;
98      }
99  
100     /**
101      * Builds an {@link AuthenticationInfo} object by querying the active directory LDAP context for the
102      * specified username.
103      */
104     @Nullable
105     @Override
106     protected AuthenticationInfo queryForAuthenticationInfo(
107             AuthenticationToken token, LdapContextFactory ldapContextFactory) throws NamingException {
108         try {
109             return queryForAuthenticationInfo0(token, ldapContextFactory);
110         } catch (ServiceUnavailableException e) {
111             // It might be a temporary failure, so try again.
112             return queryForAuthenticationInfo0(token, ldapContextFactory);
113         }
114     }
115 
116     @Nullable
117     private AuthenticationInfo queryForAuthenticationInfo0(
118             AuthenticationToken token, LdapContextFactory ldapContextFactory) throws NamingException {
119 
120         final UsernamePasswordToken upToken = ensureUsernamePasswordToken(token);
121         final String userDn = findUserDn(ldapContextFactory, upToken.getUsername());
122         if (userDn == null) {
123             return null;
124         }
125 
126         LdapContext ctx = null;
127         try {
128             // Binds using the username and password provided by the user.
129             ctx = ldapContextFactory.getLdapContext(userDn, upToken.getPassword());
130         } catch (AuthenticationException e) {
131             // According to this page, LDAP error code 49 (invalid credentials) is the only case where
132             // AuthenticationException is raised:
133             // - https://docs.oracle.com/javase/tutorial/jndi/ldap/exceptions.html
134             // - com.sun.jndi.ldap.LdapCtx.mapErrorCode()
135             return null;
136         } finally {
137             LdapUtils.closeContext(ctx);
138         }
139         return buildAuthenticationInfo(upToken.getUsername(), upToken.getPassword());
140     }
141 
142     /**
143      * Finds a distinguished name(DN) of a user by querying the active directory LDAP context for the
144      * specified username.
145      *
146      * @return the DN of the user, or {@code null} if there's no such user
147      */
148     @Nullable
149     protected String findUserDn(LdapContextFactory ldapContextFactory, String username) throws NamingException {
150         LdapContext ctx = null;
151         try {
152             // Binds using the system username and password.
153             ctx = ldapContextFactory.getSystemLdapContext();
154 
155             final SearchControls ctrl = new SearchControls();
156             ctrl.setCountLimit(1);
157             ctrl.setSearchScope(SearchControls.SUBTREE_SCOPE);
158             ctrl.setTimeLimit(searchTimeoutMillis);
159 
160             final String filter =
161                     searchFilter != null ? USERNAME_PLACEHOLDER.matcher(searchFilter)
162                                                                .replaceAll(username)
163                                          : username;
164             final NamingEnumeration<SearchResult> result = ctx.search(searchBase, filter, ctrl);
165             try {
166                 if (!result.hasMore()) {
167                     return null;
168                 }
169                 return result.next().getNameInNamespace();
170             } finally {
171                 result.close();
172             }
173         } finally {
174             LdapUtils.closeContext(ctx);
175         }
176     }
177 
178     private static UsernamePasswordToken ensureUsernamePasswordToken(AuthenticationToken token) {
179         if (token instanceof UsernamePasswordToken) {
180             return (UsernamePasswordToken) token;
181         }
182 
183         throw new IllegalArgumentException("Token '" + token.getClass().getName() + "' is not supported.");
184     }
185 }
186