View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://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,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.sso.spnego;
17  
18  import java.io.File;
19  import java.util.Arrays;
20  import java.util.Enumeration;
21  
22  import org.apache.logging.log4j.LogManager;
23  import org.apache.logging.log4j.Logger;
24  import org.codelibs.core.io.ResourceUtil;
25  import org.codelibs.core.lang.StringUtil;
26  import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
27  import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
28  import org.codelibs.fess.app.web.base.login.SpnegoCredential;
29  import org.codelibs.fess.exception.SsoLoginException;
30  import org.codelibs.fess.mylasta.action.FessUserBean;
31  import org.codelibs.fess.mylasta.direction.FessConfig;
32  import org.codelibs.fess.sso.SsoAuthenticator;
33  import org.codelibs.fess.sso.SsoResponseType;
34  import org.codelibs.fess.util.ComponentUtil;
35  import org.codelibs.spnego.SpnegoFilterConfig;
36  import org.codelibs.spnego.SpnegoHttpFilter;
37  import org.codelibs.spnego.SpnegoHttpFilter.Constants;
38  import org.codelibs.spnego.SpnegoHttpServletResponse;
39  import org.codelibs.spnego.SpnegoPrincipal;
40  import org.dbflute.optional.OptionalEntity;
41  import org.lastaflute.web.login.credential.LoginCredential;
42  import org.lastaflute.web.response.ActionResponse;
43  import org.lastaflute.web.servlet.filter.RequestLoggingFilter;
44  import org.lastaflute.web.util.LaRequestUtil;
45  import org.lastaflute.web.util.LaResponseUtil;
46  
47  import jakarta.annotation.PostConstruct;
48  import jakarta.servlet.FilterConfig;
49  import jakarta.servlet.ServletContext;
50  import jakarta.servlet.http.HttpServletResponse;
51  
52  /**
53   * SPNEGO (Security Provider Negotiation Protocol) authenticator implementation.
54   *
55   * This class provides Single Sign-On (SSO) authentication using the SPNEGO protocol,
56   * which is commonly used for Kerberos-based authentication in Windows environments.
57   * It handles the negotiation between client and server to establish a secure
58   * authentication context without requiring users to explicitly enter credentials.
59   *
60   * The authenticator supports various configuration options including delegation,
61   * basic authentication fallback, and localhost authentication bypass.
62   */
63  public class SpnegoAuthenticator implements SsoAuthenticator {
64  
65      /** Logger for this class. */
66      private static final Logger logger = LogManager.getLogger(SpnegoAuthenticator.class);
67  
68      /** Configuration key for SPNEGO initialization status. */
69      protected static final String SPNEGO_INITIALIZED = "spnego.initialized";
70  
71      /** Configuration key for directories to exclude from SPNEGO authentication. */
72      protected static final String SPNEGO_EXCLUDE_DIRS = "spnego.exclude.dirs";
73  
74      /** Configuration key for enabling delegation in SPNEGO authentication. */
75      protected static final String SPNEGO_ALLOW_DELEGATION = "spnego.allow.delegation";
76  
77      /** Configuration key for allowing localhost authentication bypass. */
78      protected static final String SPNEGO_ALLOW_LOCALHOST = "spnego.allow.localhost";
79  
80      /** Configuration key for prompting NTLM authentication. */
81      protected static final String SPNEGO_PROMPT_NTLM = "spnego.prompt.ntlm";
82  
83      /** Configuration key for allowing unsecure basic authentication. */
84      protected static final String SPNEGO_ALLOW_UNSECURE_BASIC = "spnego.allow.unsecure.basic";
85  
86      /** Configuration key for allowing basic authentication. */
87      protected static final String SPNEGO_ALLOW_BASIC = "spnego.allow.basic";
88  
89      /** Configuration key for pre-authentication password. */
90      protected static final String SPNEGO_PREAUTH_PASSWORD = "spnego.preauth.password";
91  
92      /** Configuration key for pre-authentication username. */
93      protected static final String SPNEGO_PREAUTH_USERNAME = "spnego.preauth.username";
94  
95      /** Configuration key for login server module name. */
96      protected static final String SPNEGO_LOGIN_SERVER_MODULE = "spnego.login.server.module";
97  
98      /** Configuration key for login client module name. */
99      protected static final String SPNEGO_LOGIN_CLIENT_MODULE = "spnego.login.client.module";
100 
101     /** Configuration key for Kerberos configuration file path. */
102     protected static final String SPNEGO_KRB5_CONF = "spnego.krb5.conf";
103 
104     /** Configuration key for login configuration file path. */
105     protected static final String SPNEGO_LOGIN_CONF = "spnego.login.conf";
106 
107     /** Configuration key for SPNEGO logger level. */
108     protected static final String SPNEGO_LOGGER_LEVEL = "spnego.logger.level";
109 
110     /** The underlying SPNEGO authenticator instance. */
111     protected org.codelibs.spnego.SpnegoAuthenticator authenticator = null;
112 
113     /**
114      * Constructs a new SPNEGO authenticator.
115      */
116     public SpnegoAuthenticator() {
117         // do nothing
118     }
119 
120     /**
121      * Initializes the SPNEGO authenticator and registers it with the SSO manager.
122      * This method is called automatically after dependency injection is complete.
123      */
124     @PostConstruct
125     public void init() {
126         if (logger.isDebugEnabled()) {
127             logger.debug("Initializing {}", this.getClass().getSimpleName());
128         }
129         ComponentUtil.getSsoManager().register(this);
130     }
131 
132     /**
133      * Gets or creates the SPNEGO authenticator instance.
134      *
135      * This method implements lazy initialization with synchronization to ensure
136      * the authenticator is only created once. It configures the authenticator
137      * with the appropriate SPNEGO settings and marks initialization as complete.
138      *
139      * @return The configured SPNEGO authenticator instance
140      * @throws SsoLoginException if SPNEGO initialization fails
141      */
142     protected synchronized org.codelibs.spnego.SpnegoAuthenticator getAuthenticator() {
143         final FessConfig fessConfig = ComponentUtil.getFessConfig();
144         if (authenticator != null && fessConfig.getSystemPropertyAsBoolean(SPNEGO_INITIALIZED, false)) {
145             return authenticator;
146         }
147         try {
148             // set some System properties
149             final SpnegoFilterConfig config = SpnegoFilterConfig.getInstance(new SpnegoConfig());
150 
151             // pre-authenticate
152             authenticator = new org.codelibs.spnego.SpnegoAuthenticator(config);
153 
154             fessConfig.setSystemPropertyAsBoolean(SPNEGO_INITIALIZED, true);
155             fessConfig.storeSystemProperties();
156             return authenticator;
157         } catch (final Exception e) {
158             throw new SsoLoginException("Failed to initialize SPNEGO.", e);
159         }
160     }
161 
162     /**
163      * Attempts to obtain login credentials using SPNEGO authentication.
164      *
165      * This method processes the HTTP request to extract and validate SPNEGO
166      * authentication tokens. It handles the SPNEGO handshake process and
167      * extracts the user principal from successful authentication.
168      *
169      * @return The login credential containing the authenticated username,
170      *         an ActionResponseCredential for authentication challenges,
171      *         or null if no authentication information is available
172      * @throws SsoLoginException if SPNEGO authentication fails
173      */
174     @Override
175     public LoginCredential getLoginCredential() {
176         return LaRequestUtil.getOptionalRequest().map(request -> {
177             if (logger.isDebugEnabled()) {
178                 logger.debug("Logging in with SPNEGO Authenticator");
179             }
180             final HttpServletResponse response = LaResponseUtil.getResponse();
181             final SpnegoHttpServletResponse spnegoResponse = new SpnegoHttpServletResponse(response);
182 
183             // client/caller principal
184             final SpnegoPrincipal principal;
185             try {
186                 principal = getAuthenticator().authenticate(request, spnegoResponse);
187                 if (logger.isDebugEnabled()) {
188                     logger.debug("principal={}", principal);
189                 }
190             } catch (final Exception e) {
191                 final String authzHeader = request.getHeader(Constants.AUTHZ_HEADER);
192                 final String maskedHeader;
193                 if (authzHeader == null) {
194                     maskedHeader = "null";
195                 } else if (authzHeader.length() <= 10) {
196                     maskedHeader = "***";
197                 } else {
198                     maskedHeader = authzHeader.substring(0, 10) + "***";
199                 }
200                 final String msg = "Failed to process Authorization Header: " + maskedHeader;
201                 if (logger.isDebugEnabled()) {
202                     logger.debug(msg);
203                 }
204                 throw new SsoLoginException(e.getMessage() + " " + msg, e);
205             }
206 
207             // context/auth loop not yet complete
208             final boolean status = spnegoResponse.isStatusSet();
209             if (logger.isDebugEnabled()) {
210                 logger.debug("isStatusSet={}", status);
211             }
212             if (status) {
213                 return new ActionResponseCredential(() -> {
214                     throw new RequestLoggingFilter.RequestClientErrorException("Your request is not authorized.", "401 Unauthorized",
215                             HttpServletResponse.SC_UNAUTHORIZED);
216                 });
217             }
218 
219             // assert
220             if (null == principal) {
221                 final String msg = "Principal was null.";
222                 if (logger.isDebugEnabled()) {
223                     logger.debug(msg);
224                 }
225                 throw new SsoLoginException(msg);
226             }
227 
228             if (logger.isDebugEnabled()) {
229                 logger.debug("principal={}", principal);
230             }
231 
232             final String[] username = principal.getName().split("@", 2);
233             if (logger.isDebugEnabled()) {
234                 logger.debug("username={}", Arrays.toString(username));
235             }
236             return new SpnegoCredential(username[0]);
237         }).orElse(null);
238 
239     }
240 
241     /**
242      * SPNEGO filter configuration implementation.
243      *
244      * This inner class provides configuration parameters for the SPNEGO filter,
245      * mapping system properties to SPNEGO configuration values. It handles
246      * various authentication settings including Kerberos configuration,
247      * authentication modules, and security options.
248      */
249     protected static class SpnegoConfig implements FilterConfig {
250 
251         /**
252          * Constructs a new SPNEGO filter configuration.
253          */
254         public SpnegoConfig() {
255             // do nothing
256         }
257 
258         /**
259          * Gets the filter name for this SPNEGO configuration.
260          *
261          * @return The fully qualified class name of SpnegoAuthenticator
262          */
263         @Override
264         public String getFilterName() {
265             return SpnegoAuthenticator.class.getName();
266         }
267 
268         /**
269          * Gets the servlet context. This operation is not supported.
270          *
271          * @return Never returns, always throws UnsupportedOperationException
272          * @throws UnsupportedOperationException Always thrown as this operation is not supported
273          */
274         @Override
275         public ServletContext getServletContext() {
276             throw new UnsupportedOperationException("getServletContext() is not supported in SpnegoFilterConfig");
277         }
278 
279         /**
280          * Gets the initialization parameter value for the given parameter name.
281          *
282          * This method maps SPNEGO configuration parameter names to their corresponding
283          * values from system properties or default values. It handles various
284          * authentication and security settings for SPNEGO.
285          *
286          * @param name The name of the initialization parameter
287          * @return The parameter value, or null if not found
288          */
289         @Override
290         public String getInitParameter(final String name) {
291             if (SpnegoHttpFilter.Constants.LOGGER_LEVEL.equals(name)) {
292                 final String logLevel = getProperty(SPNEGO_LOGGER_LEVEL, StringUtil.EMPTY);
293                 if (StringUtil.isNotBlank(logLevel)) {
294                     return logLevel;
295                 }
296                 if (logger.isDebugEnabled()) {
297                     return "3";
298                 }
299                 if (logger.isInfoEnabled()) {
300                     return "5";
301                 }
302                 if (logger.isWarnEnabled()) {
303                     return "6";
304                 }
305                 if (logger.isErrorEnabled()) {
306                     return "7";
307                 }
308                 return "0";
309             }
310             if (SpnegoHttpFilter.Constants.LOGIN_CONF.equals(name)) {
311                 return getResourcePath(getProperty(SPNEGO_LOGIN_CONF, "auth_login.conf"));
312             }
313             if (SpnegoHttpFilter.Constants.KRB5_CONF.equals(name)) {
314                 return getResourcePath(getProperty(SPNEGO_KRB5_CONF, "krb5.conf"));
315             }
316             if (SpnegoHttpFilter.Constants.CLIENT_MODULE.equals(name)) {
317                 return getProperty(SPNEGO_LOGIN_CLIENT_MODULE, "spnego-client");
318             }
319             if (SpnegoHttpFilter.Constants.SERVER_MODULE.equals(name)) {
320                 return getProperty(SPNEGO_LOGIN_SERVER_MODULE, "spnego-server");
321             }
322             if (SpnegoHttpFilter.Constants.PREAUTH_USERNAME.equals(name)) {
323                 return getProperty(SPNEGO_PREAUTH_USERNAME, "username");
324             }
325             if (SpnegoHttpFilter.Constants.PREAUTH_PASSWORD.equals(name)) {
326                 return getProperty(SPNEGO_PREAUTH_PASSWORD, "password");
327             }
328             if (SpnegoHttpFilter.Constants.ALLOW_BASIC.equals(name)) {
329                 // SECURITY NOTE: Basic authentication is enabled by default for compatibility.
330                 // For production, consider setting spnego.allow.basic to false.
331                 return getProperty(SPNEGO_ALLOW_BASIC, "true");
332             }
333             if (SpnegoHttpFilter.Constants.ALLOW_UNSEC_BASIC.equals(name)) {
334                 // SECURITY WARNING: Unsecure basic authentication is enabled by default.
335                 // This sends credentials in Base64 encoding over potentially unencrypted connections.
336                 // For production, it is STRONGLY RECOMMENDED to set spnego.allow.unsecure.basic to false
337                 // and use HTTPS or more secure authentication methods.
338                 return getProperty(SPNEGO_ALLOW_UNSECURE_BASIC, "true");
339             }
340             if (SpnegoHttpFilter.Constants.PROMPT_NTLM.equals(name)) {
341                 return getProperty(SPNEGO_PROMPT_NTLM, "true");
342             }
343             if (SpnegoHttpFilter.Constants.ALLOW_LOCALHOST.equals(name)) {
344                 return getProperty(SPNEGO_ALLOW_LOCALHOST, "true");
345             }
346             if (SpnegoHttpFilter.Constants.ALLOW_DELEGATION.equals(name)) {
347                 return getProperty(SPNEGO_ALLOW_DELEGATION, "false");
348             }
349             if (SpnegoHttpFilter.Constants.EXCLUDE_DIRS.equals(name)) {
350                 return getProperty(SPNEGO_EXCLUDE_DIRS, StringUtil.EMPTY);
351             }
352             return null;
353         }
354 
355         /**
356          * Gets a system property value with a default fallback.
357          *
358          * @param key The property key to look up
359          * @param defaultValue The default value to return if the property is not set
360          * @return The property value or the default value
361          */
362         protected String getProperty(final String key, final String defaultValue) {
363             return ComponentUtil.getSystemProperties().getProperty(key, defaultValue);
364         }
365 
366         /**
367          * Resolves a resource path to an absolute file path.
368          *
369          * @param path The resource path to resolve
370          * @return The absolute file path of the resource, or null if not found
371          */
372         protected String getResourcePath(final String path) {
373             final File file = ResourceUtil.getResourceAsFileNoException(path);
374             if (file != null) {
375                 return file.getAbsolutePath();
376             }
377             return null;
378         }
379 
380         /**
381          * Gets the names of all initialization parameters. This operation is not supported.
382          *
383          * @return Never returns, always throws UnsupportedOperationException
384          * @throws UnsupportedOperationException Always thrown as this operation is not supported
385          */
386         @Override
387         public Enumeration<String> getInitParameterNames() {
388             throw new UnsupportedOperationException("getInitParameterNames() is not supported in SpnegoFilterConfig");
389         }
390 
391     }
392 
393     /**
394      * Resolves the SPNEGO credential to a user entity.
395      *
396      * This method handles the resolution of SPNEGO credentials by checking
397      * if the user is an admin user or needs to be authenticated through LDAP.
398      *
399      * @param resolver The credential resolver to use for user lookup
400      */
401     @Override
402     public void resolveCredential(final LoginCredentialResolver resolver) {
403         resolver.resolve(SpnegoCredential.class, credential -> {
404             final String username = credential.getUserId();
405             if (!ComponentUtil.getFessConfig().isAdminUser(username)) {
406                 return ComponentUtil.getLdapManager().login(username);
407             }
408             return OptionalEntity.empty();
409         });
410     }
411 
412     /**
413      * Gets the action response for the specified SSO response type.
414      *
415      * SPNEGO authentication typically doesn't require special response handling
416      * for metadata or logout operations, so this method returns null.
417      *
418      * @param responseType The type of SSO response requested
419      * @return Always returns null for SPNEGO authentication
420      */
421     @Override
422     public ActionResponse getResponse(final SsoResponseType responseType) {
423         return null;
424     }
425 
426     /**
427      * Performs logout for the specified user.
428      *
429      * SPNEGO authentication relies on the underlying Kerberos infrastructure
430      * for session management, so no specific logout URL is provided.
431      *
432      * @param user The user to logout
433      * @return Always returns null as SPNEGO doesn't provide a logout URL
434      */
435     @Override
436     public String logout(final FessUserBean user) {
437         return null;
438     }
439 
440 }