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.saml;
17  
18  import java.io.OutputStreamWriter;
19  import java.io.Writer;
20  import java.util.HashMap;
21  import java.util.List;
22  import java.util.Map;
23  import java.util.stream.Collectors;
24  
25  import org.apache.logging.log4j.LogManager;
26  import org.apache.logging.log4j.Logger;
27  import org.codelibs.core.lang.StringUtil;
28  import org.codelibs.core.misc.DynamicProperties;
29  import org.codelibs.core.net.UuidUtil;
30  import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
31  import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
32  import org.codelibs.fess.app.web.base.login.SamlCredential;
33  import org.codelibs.fess.app.web.base.login.SamlCredential.SamlUser;
34  import org.codelibs.fess.crawler.Constants;
35  import org.codelibs.fess.exception.SsoLoginException;
36  import org.codelibs.fess.exception.SsoMessageException;
37  import org.codelibs.fess.exception.SsoProcessException;
38  import org.codelibs.fess.mylasta.action.FessUserBean;
39  import org.codelibs.fess.sso.SsoAuthenticator;
40  import org.codelibs.fess.sso.SsoResponseType;
41  import org.codelibs.fess.util.ComponentUtil;
42  import org.codelibs.saml2.Auth;
43  import org.codelibs.saml2.core.authn.AuthnRequestParams;
44  import org.codelibs.saml2.core.logout.LogoutRequestParams;
45  import org.codelibs.saml2.core.settings.Saml2Settings;
46  import org.codelibs.saml2.core.settings.SettingsBuilder;
47  import org.dbflute.optional.OptionalEntity;
48  import org.lastaflute.core.message.UserMessages;
49  import org.lastaflute.web.login.credential.LoginCredential;
50  import org.lastaflute.web.response.ActionResponse;
51  import org.lastaflute.web.response.HtmlResponse;
52  import org.lastaflute.web.response.StreamResponse;
53  import org.lastaflute.web.util.LaRequestUtil;
54  import org.lastaflute.web.util.LaResponseUtil;
55  
56  import jakarta.annotation.PostConstruct;
57  import jakarta.servlet.http.HttpServletRequest;
58  import jakarta.servlet.http.HttpServletResponse;
59  import jakarta.servlet.http.HttpSession;
60  
61  /**
62   * Authenticator for SAML 2.0.
63   *
64   * <p>This authenticator enables Single Sign-On (SSO) using SAML 2.0 protocol
65   * with Identity Providers such as Okta, Azure AD, OneLogin, etc.</p>
66   *
67   * <h2>Required Configuration</h2>
68   * <p>Add the following properties to {@code system.properties}:</p>
69   * <pre>
70   * # Enable SAML SSO
71   * sso.type=saml
72   *
73   * # Identity Provider settings (obtain from your IdP)
74   * saml.idp.entityid=http://www.okta.com/xxxxx
75   * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/xxxxx/sso/saml
76   * saml.idp.x509cert=MIIDqjCCApKgAwIBAgIGAYMwfYAwMA0G...
77   * </pre>
78   *
79   * <h2>Service Provider URL Configuration</h2>
80   * <p>By default, the SP URLs use {@code http://localhost:8080} as the base URL.
81   * For production or when the IdP is configured with a different URL, you should
82   * set one of the following:</p>
83   *
84   * <h3>Option 1: Set base URL (recommended for simplicity)</h3>
85   * <pre>
86   * # All SP URLs will be derived from this base URL
87   * saml.sp.base.url=https://your-fess-server.example.com
88   * </pre>
89   *
90   * <h3>Option 2: Set individual SP URLs</h3>
91   * <pre>
92   * # SP Entity ID (Audience URI in IdP)
93   * saml.sp.entityid=https://your-fess-server.example.com/sso/metadata
94   *
95   * # Assertion Consumer Service URL
96   * saml.sp.assertion_consumer_service.url=https://your-fess-server.example.com/sso/
97   *
98   * # Single Logout Service URL
99   * saml.sp.single_logout_service.url=https://your-fess-server.example.com/sso/logout
100  * </pre>
101  *
102  * <h2>Complete Configuration Example (Okta)</h2>
103  * <pre>
104  * sso.type=saml
105  *
106  * # IdP settings from Okta SAML setup instructions
107  * saml.idp.entityid=http://www.okta.com/your-app-id
108  * saml.idp.single_sign_on_service.url=https://your-domain.okta.com/app/your-app/your-app-id/sso/saml
109  * saml.idp.x509cert=MIIDqjCCApKg... (your IdP certificate)
110  *
111  * # SP base URL (must match Audience URI configured in Okta)
112  * saml.sp.base.url=https://your-fess-server.example.com
113  * </pre>
114  *
115  * <h2>Optional Configuration</h2>
116  * <pre>
117  * # User attribute mapping
118  * saml.attribute.group.name=groups
119  * saml.attribute.role.name=roles
120  *
121  * # Default groups/roles for authenticated users
122  * saml.default.groups=user
123  * saml.default.roles=user
124  * </pre>
125  *
126  * <h2>Security Settings (Production)</h2>
127  * <p>For production environments, consider enabling these security features:</p>
128  * <pre>
129  * saml.security.authnrequest_signed=true
130  * saml.security.want_messages_signed=true
131  * saml.security.want_assertions_signed=true
132  * </pre>
133  *
134  * @see <a href="https://fess.codelibs.org/">Fess Documentation</a>
135  */
136 public class SamlAuthenticator implements SsoAuthenticator {
137 
138     /**
139      * Constructor.
140      */
141     public SamlAuthenticator() {
142         super();
143     }
144 
145     private static final Logger logger = LogManager.getLogger(SamlAuthenticator.class);
146 
147     /**
148      * The prefix for SAML properties.
149      */
150     protected static final String SAML_PREFIX = "saml.";
151 
152     /**
153      * The key for the SAML state in the session.
154      */
155     protected static final String SAML_STATE = "SAML_STATE";
156 
157     /**
158      * The property key for the SAML SP base URL.
159      */
160     protected static final String SAML_SP_BASE_URL = "saml.sp.base.url";
161 
162     private Map<String, Object> defaultSettings;
163 
164     /**
165      * Initializes the SamlAuthenticator.
166      */
167     @PostConstruct
168     public void init() {
169         if (logger.isDebugEnabled()) {
170             logger.debug("Initializing {}", this.getClass().getSimpleName());
171         }
172         ComponentUtil.getSsoManager().register(this);
173 
174         // Default SAML settings
175         // NOTE: Many security settings are set to false by default for compatibility.
176         // For production use, it is STRONGLY RECOMMENDED to enable security features:
177         // - onelogin.saml2.security.authnrequest_signed
178         // - onelogin.saml2.security.want_messages_signed
179         // - onelogin.saml2.security.want_assertions_signed
180         // Override these settings in your system properties with the 'saml.' prefix.
181         defaultSettings = new HashMap<>();
182         defaultSettings.put("onelogin.saml2.strict", "true");
183         defaultSettings.put("onelogin.saml2.debug", "false");
184         defaultSettings.put("onelogin.saml2.sp.entityid", buildDefaultUrl("/sso/metadata"));
185         defaultSettings.put("onelogin.saml2.sp.assertion_consumer_service.url", buildDefaultUrl("/sso/"));
186         defaultSettings.put("onelogin.saml2.sp.assertion_consumer_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
187         defaultSettings.put("onelogin.saml2.sp.single_logout_service.url", buildDefaultUrl("/sso/logout"));
188         defaultSettings.put("onelogin.saml2.sp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
189         defaultSettings.put("onelogin.saml2.sp.nameidformat", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
190         defaultSettings.put("onelogin.saml2.sp.x509cert", "");
191         defaultSettings.put("onelogin.saml2.sp.privatekey", "");
192         defaultSettings.put("onelogin.saml2.idp.single_sign_on_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
193         defaultSettings.put("onelogin.saml2.idp.single_logout_service.response.url", "");
194         defaultSettings.put("onelogin.saml2.idp.single_logout_service.binding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect");
195         defaultSettings.put("onelogin.saml2.security.nameid_encrypted", "false");
196         defaultSettings.put("onelogin.saml2.security.authnrequest_signed", "false");
197         defaultSettings.put("onelogin.saml2.security.logoutrequest_signed", "false");
198         defaultSettings.put("onelogin.saml2.security.logoutresponse_signed", "false");
199         defaultSettings.put("onelogin.saml2.security.want_messages_signed", "false");
200         defaultSettings.put("onelogin.saml2.security.want_assertions_signed", "false");
201         defaultSettings.put("onelogin.saml2.security.sign_metadata", "");
202         defaultSettings.put("onelogin.saml2.security.want_assertions_encrypted", "false");
203         defaultSettings.put("onelogin.saml2.security.want_nameid_encrypted", "false");
204         defaultSettings.put("onelogin.saml2.security.requested_authncontext", "urn:oasis:names:tc:SAML:2.0:ac:classes:Password");
205         defaultSettings.put("onelogin.saml2.security.onelogin.saml2.security.requested_authncontextcomparison", "exact");
206         defaultSettings.put("onelogin.saml2.security.want_xml_validation", "true");
207         defaultSettings.put("onelogin.saml2.security.signature_algorithm", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256");
208         defaultSettings.put("onelogin.saml2.organization.name", "CodeLibs");
209         defaultSettings.put("onelogin.saml2.organization.displayname", "Fess");
210         defaultSettings.put("onelogin.saml2.organization.url", "https://fess.codelibs.org/");
211         defaultSettings.put("onelogin.saml2.organization.lang", "");
212         defaultSettings.put("onelogin.saml2.contacts.technical.given_name", "Technical Guy");
213         defaultSettings.put("onelogin.saml2.contacts.technical.email_address", "technical@example.com");
214         defaultSettings.put("onelogin.saml2.contacts.support.given_name", "Support Guy");
215         defaultSettings.put("onelogin.saml2.contacts.support.email_address", "support@example.com");
216     }
217 
218     /**
219      * Builds a default URL for SAML endpoints.
220      * Uses the configured base URL or defaults to http://localhost:8080 for compatibility
221      * with common SAML IdP configurations.
222      *
223      * @param path the path to append to the base URL
224      * @return the complete URL
225      */
226     protected String buildDefaultUrl(final String path) {
227         final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
228         String baseUrl = systemProperties.getProperty(SAML_SP_BASE_URL);
229         if (StringUtil.isBlank(baseUrl)) {
230             baseUrl = "http://localhost:8080";
231         }
232         if (baseUrl.endsWith("/")) {
233             baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
234         }
235         return baseUrl + path;
236     }
237 
238     /**
239      * Gets the SAML settings.
240      * @return The SAML settings.
241      */
242     protected Saml2Settings getSettings() {
243         final Map<String, Object> params = new HashMap<>(defaultSettings);
244         final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
245         systemProperties.entrySet().stream().forEach(e -> {
246             final String key = e.getKey().toString();
247             if (!key.startsWith(SAML_PREFIX)) {
248                 return;
249             }
250             params.put("onelogin.saml2." + key.substring(SAML_PREFIX.length()), e.getValue());
251         });
252         return new SettingsBuilder().fromValues(params).build();
253     }
254 
255     @Override
256     public LoginCredential getLoginCredential() {
257         return LaRequestUtil.getOptionalRequest().map(request -> {
258             if (logger.isDebugEnabled()) {
259                 logger.debug("Logging in with SAML Authenticator");
260             }
261 
262             final HttpServletResponse response = LaResponseUtil.getResponse();
263 
264             final HttpSession session = request.getSession(false);
265             if (session != null) {
266                 final String sesState = (String) session.getAttribute(SAML_STATE);
267                 if (StringUtil.isNotBlank(sesState)) {
268                     session.removeAttribute(SAML_STATE);
269                     try {
270                         final Auth auth = new Auth(getSettings(), request, response);
271                         auth.processResponse();
272 
273                         if (!auth.isAuthenticated()) {
274                             if (logger.isDebugEnabled()) {
275                                 logger.debug("Authentication failed.");
276                             }
277                             return null;
278                         }
279 
280                         final List<String> errors = auth.getErrors();
281                         if (!errors.isEmpty()) {
282                             logger.warn("{}", errors.stream().collect(Collectors.joining(", ")));
283                             if (auth.isDebugActive() && StringUtil.isNotBlank(auth.getLastErrorReason())) {
284                                 logger.warn("Authentication Failure: {} - Reason: {}", errors.stream().collect(Collectors.joining(", ")),
285                                         auth.getLastErrorReason());
286                             } else {
287                                 logger.warn("Authentication Failure: {}", errors.stream().collect(Collectors.joining(", ")));
288                             }
289                             return null;
290                         }
291 
292                         return createLoginCredential(request, response, auth);
293                     } catch (final Exception e) {
294                         logger.warn("Authentication failed.", e);
295                         return null;
296                     }
297                 }
298             }
299 
300             try {
301                 final Auth auth = new Auth(getSettings(), request, response);
302                 final AuthnRequestParams authnRequestParams = new AuthnRequestParams(false, false, true);
303                 final String loginUrl = auth.login(null, authnRequestParams, true);
304                 request.getSession().setAttribute(SAML_STATE, UuidUtil.create());
305                 return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(loginUrl));
306             } catch (final Exception e) {
307                 throw new SsoLoginException("Invalid SAML redirect URL.", e);
308             }
309 
310         }).orElse(null);
311     }
312 
313     /**
314      * Creates a login credential.
315      * @param request The HTTP request.
316      * @param response The HTTP response.
317      * @param auth The SAML authentication.
318      * @return The login credential.
319      */
320     protected LoginCredential createLoginCredential(final HttpServletRequest request, final HttpServletResponse response, final Auth auth) {
321         final SamlCredential samlCredential = new SamlCredential(auth);
322         if (logger.isDebugEnabled()) {
323             logger.debug("SamlCredential: {}", samlCredential);
324         }
325         return samlCredential;
326     }
327 
328     @Override
329     public void resolveCredential(final LoginCredentialResolver resolver) {
330         resolver.resolve(SamlCredential.class, credential -> OptionalEntity.of(credential.getUser()));
331     }
332 
333     @Override
334     public String logout(final FessUserBean user) {
335         if (user.getFessUser() instanceof SamlUser) {
336             return LaRequestUtil.getOptionalRequest().map(request -> {
337                 if (logger.isDebugEnabled()) {
338                     logger.debug("Logging out with SAML Authenticator");
339                 }
340                 final HttpServletResponse response = LaResponseUtil.getResponse();
341                 final SamlUser samlUser = (SamlUser) user.getFessUser();
342                 try {
343                     final Saml2Settings settings = getSettings();
344                     if (settings.getIdpSingleLogoutServiceUrl() == null) {
345                         if (logger.isDebugEnabled()) {
346                             logger.debug("IdP single logout service URL is not configured, skipping SLO for user: {}", samlUser);
347                         }
348                         return null;
349                     }
350                     final Auth auth = new Auth(settings, request, response);
351                     final LogoutRequestParams logoutRequestParams = new LogoutRequestParams(samlUser.getSessionIndex(), samlUser.getName(),
352                             samlUser.getNameIdFormat(), samlUser.getNameidNameQualifier(), samlUser.getNameidSPNameQualifier());
353                     return auth.logout(null, logoutRequestParams, true);
354                 } catch (final Exception e) {
355                     logger.warn("Failed to logout from IdP: name={}", samlUser.getName(), e);
356                 }
357                 return null;
358             }).orElse(null);
359         }
360         return null;
361     }
362 
363     @Override
364     public ActionResponse getResponse(final SsoResponseType responseType) {
365         return switch (responseType) {
366         case METADATA -> getMetadataResponse();
367         case LOGOUT -> getLogoutResponse();
368         default -> null;
369         };
370     }
371 
372     /**
373      * Gets the metadata response.
374      * @return The metadata response.
375      */
376     protected ActionResponse getMetadataResponse() {
377         return LaRequestUtil.getOptionalRequest().map(request -> {
378             if (logger.isDebugEnabled()) {
379                 logger.debug("Accessing metadata with SAML Authenticator");
380             }
381             final HttpServletResponse response = LaResponseUtil.getResponse();
382             try {
383                 final Auth auth = new Auth(getSettings(), request, response);
384                 final Saml2Settings settings = auth.getSettings();
385                 settings.setSPValidationOnly(true);
386                 final String metadata = settings.getSPMetadata();
387                 final List<String> errors = Saml2Settings.validateMetadata(metadata);
388                 if (!errors.isEmpty()) {
389                     final String msg = errors.stream().collect(Collectors.joining(", "));
390                     throw new SsoMessageException(
391                             messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, msg),
392                             "Failed to log out.", new SsoProcessException(msg));
393                 }
394                 return new StreamResponse("metadata").contentType("application/xhtml+xml").stream(out -> {
395                     try (final Writer writer = new OutputStreamWriter(out.stream(), Constants.UTF_8_CHARSET)) {
396                         writer.write(metadata);
397                     }
398                 });
399             } catch (final SsoMessageException e) {
400                 throw e;
401             } catch (final Exception e) {
402                 throw new SsoMessageException(
403                         messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, e.getMessage()),
404                         "Failed to process metadata.", e);
405             }
406         })
407                 .orElseThrow(() -> new SsoMessageException(
408                         messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, "Invalid state."),
409                         "Failed to process metadata.", new SsoProcessException("Invalid state.")));
410     }
411 
412     /**
413      * Gets the logout response.
414      * @return The logout response.
415      */
416     protected ActionResponse getLogoutResponse() {
417         LaRequestUtil.getOptionalRequest().map(request -> {
418             if (logger.isDebugEnabled()) {
419                 logger.debug("Logging out with SAML Authenticator");
420             }
421             final HttpServletResponse response = LaResponseUtil.getResponse();
422             try {
423                 final Auth auth = new Auth(getSettings(), request, response);
424                 auth.processSLO();
425                 final List<String> errors = auth.getErrors();
426                 if (errors.isEmpty()) {
427                     throw new SsoMessageException(messages -> messages.addSuccessSsoLogout(UserMessages.GLOBAL_PROPERTY_KEY), "Logged out");
428                 }
429                 final String msg = errors.stream().collect(Collectors.joining(", "));
430                 throw new SsoMessageException(
431                         messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, msg),
432                         "Failed to log out.", new SsoProcessException(msg));
433             } catch (final SsoMessageException e) {
434                 throw e;
435             } catch (final Exception e) {
436                 throw new SsoMessageException(
437                         messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, e.getMessage()),
438                         "Failed to log out.", e);
439             }
440         })
441                 .orElseThrow(() -> new SsoMessageException(
442                         messages -> messages.addErrorsFailedToProcessSsoRequest(UserMessages.GLOBAL_PROPERTY_KEY, "Invalid state."),
443                         "Failed to log out.", new SsoProcessException("Invalid state.")));
444         return null;
445     }
446 }