diff --git a/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java b/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java index 11f61a12ae2..cc275774a16 100644 --- a/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java +++ b/solr/core/src/java/org/apache/solr/core/SolrResourceLoader.java @@ -108,6 +108,7 @@ public class SolrResourceLoader "handler.admin.", "security.jwt.", "security.hadoop.", + "security.cert.", "handler.sql.", "hdfs.", "hdfs.update.", diff --git a/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java index ce59a240f91..fa8d08f70fa 100644 --- a/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java +++ b/solr/core/src/java/org/apache/solr/security/CertAuthPlugin.java @@ -16,17 +16,86 @@ */ package org.apache.solr.security; +import java.io.IOException; +import java.lang.invoke.MethodHandles; import java.security.cert.X509Certificate; import java.util.Map; import javax.servlet.FilterChain; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.http.HttpHeaders; +import org.apache.solr.common.util.StrUtils; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.security.cert.CertPrincipalResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** An authentication plugin that sets principal based on the certificate subject */ public class CertAuthPlugin extends AuthenticationPlugin { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String PARAM_PRINCIPAL_RESOLVER = "principalResolver"; + private static final String PARAM_CLASS = "class"; + private static final String PARAM_PARAMS = "params"; + + private static final CertPrincipalResolver DEFAULT_PRINCIPAL_RESOLVER = + certificate -> certificate.getSubjectX500Principal(); + protected final CoreContainer coreContainer; + private CertPrincipalResolver principalResolver; + + public CertAuthPlugin() { + this(null); + } + + public CertAuthPlugin(CoreContainer coreContainer) { + this.coreContainer = coreContainer; + } + @Override - public void init(Map pluginConfig) {} + public void init(Map pluginConfig) { + principalResolver = + resolveComponent( + pluginConfig, + PARAM_PRINCIPAL_RESOLVER, + CertPrincipalResolver.class, + DEFAULT_PRINCIPAL_RESOLVER, + "principalResolver"); + } + + @SuppressWarnings("unchecked") + private T resolveComponent( + Map pluginConfig, + String configKey, + Class clazz, + T defaultInstance, + String componentName) { + Map configMap = (Map) pluginConfig.get(configKey); + if (this.coreContainer == null) { + log.warn("No coreContainer configured. Using the default {}", componentName); + return defaultInstance; + } + if (configMap == null) { + log.warn("No {} configured. Using the default one", componentName); + return defaultInstance; + } + + String className = (String) configMap.get(PARAM_CLASS); + if (StrUtils.isNullOrEmpty(className)) { + log.warn("No {} class configured. Using the default one", componentName); + return defaultInstance; + } + Map params = (Map) configMap.get(PARAM_PARAMS); + if (params == null) { + log.warn("No params found for {}. Using the default class", componentName); + return defaultInstance; + } + + log.info("Found a {} class: {}", componentName, className); + return this.coreContainer + .getResourceLoader() + .newInstance(className, clazz, null, new Class[] {Map.class}, new Object[] {params}); + } @Override public boolean doAuthenticate( @@ -35,15 +104,20 @@ public boolean doAuthenticate( X509Certificate[] certs = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); if (certs == null || certs.length == 0) { - numMissingCredentials.inc(); - response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate"); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "require certificate"); - return false; + return sendError(response, "require certificate"); } - HttpServletRequest wrapped = wrapWithPrincipal(request, certs[0].getSubjectX500Principal()); + HttpServletRequest wrapped = + wrapWithPrincipal(request, principalResolver.resolvePrincipal(certs[0])); numAuthenticated.inc(); filterChain.doFilter(wrapped, response); return true; } + + private boolean sendError(HttpServletResponse response, String msg) throws IOException { + numMissingCredentials.inc(); + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Certificate"); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, msg); + return false; + } } diff --git a/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java b/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java new file mode 100644 index 00000000000..b4891141205 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/CertPrincipalResolver.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security.cert; + +import java.security.Principal; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import javax.net.ssl.SSLPeerUnverifiedException; + +/** + * Defines the interface for resolving a {@link Principal} from an X509 certificate. Implementations + * of this interface are responsible for extracting a specific piece of information from the + * certificate and converting it into a {@link Principal}. + */ +public interface CertPrincipalResolver { + /** + * Resolves a {@link Principal} from the given X509 certificate. + * + *

This method is intended to extract principal information, such as a common name (CN) or an + * email address, from the specified certificate and encapsulate it into a {@link Principal} + * object. The specific field or attribute of the certificate to be used as the principal, and the + * logic for its extraction, is defined by the implementation. + * + *

@param certificate The X509Certificate from which to resolve the principal. + * + * @return A {@link Principal} object representing the resolved principal from the certificate. + * @throws SSLPeerUnverifiedException If the peer's identity has not been verified. + * @throws CertificateParsingException If an error occurs while parsing the certificate for + * principal information. + */ + Principal resolvePrincipal(X509Certificate certificate) + throws SSLPeerUnverifiedException, CertificateParsingException; +} diff --git a/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.java b/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.java new file mode 100644 index 00000000000..802743a516d --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/CertResolverPattern.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security.cert; + +import java.lang.invoke.MethodHandles; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Represents a pattern for resolving certificate information, specifying the criteria for + * extracting and matching values from certificates. + */ +public class CertResolverPattern { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final String name; + private final String path; + private final CheckType checkType; + private final Set filterValues; + + /** + * Constructs a CertResolverPattern with specified parameters. + * + * @param name The name associated with this pattern. + * @param path The certificate field path this pattern applies to. + * @param checkType The type of check to perform on extracted values. + * @param filterValues The set of values to check against the extracted certificate field. + */ + public CertResolverPattern(String name, String path, String checkType, Set filterValues) { + this.name = name; + this.path = path; + this.checkType = CheckType.fromString(checkType); + this.filterValues = filterValues; + } + + public String getName() { + return name; + } + + public String getPath() { + return path; + } + + public CheckType getCheckType() { + return checkType; + } + + public Set getFilterValues() { + return filterValues; + } + + public static boolean matchesPattern(String value, CertResolverPattern pattern) { + return matchesPattern(value, pattern.getCheckType(), pattern.getFilterValues()); + } + + /** + * Determines if a given value matches the specified filter values, depending on the check type. + * + * @param value The value to check. + * @param checkType The type of check to perform. + * @param values The set of values to check against. + * @return True if the value matches the criteria; false otherwise. + */ + public static boolean matchesPattern(String value, CheckType checkType, Set values) { + log.debug("matchesPattern value:{} checkType:{} values:{}", value, checkType, values); + String lowerValue = + value.toLowerCase(Locale.ROOT); // lowercase for case-insensitive comparisons + switch (checkType) { + case EQUALS: + return values.contains(lowerValue); + case STARTS_WITH: + return values.stream().anyMatch(lowerValue::startsWith); + case ENDS_WITH: + return values.stream().anyMatch(lowerValue::endsWith); + case CONTAINS: + return values.stream().anyMatch(lowerValue::contains); + case WILDCARD: + return true; + default: + return false; + } + } + + /** Enum defining the types of checks that can be performed on extracted certificate values. */ + public enum CheckType { + STARTS_WITH, + ENDS_WITH, + CONTAINS, + EQUALS, + WILDCARD; + // TODO: add regex support + + private static final Map lookup = + Map.of( + "equals", EQUALS, + "startswith", STARTS_WITH, + "endswith", ENDS_WITH, + "contains", CONTAINS, + "*", WILDCARD, + "wildcard", WILDCARD); + + public static CheckType fromString(String checkType) { + if (checkType == null) { + throw new IllegalArgumentException("CheckType cannot be null"); + } + CheckType result = lookup.get(checkType.toLowerCase(Locale.ROOT)); + if (result == null) { + throw new IllegalArgumentException("No CheckType with text '" + checkType + "' found"); + } + return result; + } + } +} diff --git a/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java b/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java new file mode 100644 index 00000000000..5a4e06bbe61 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/CertUtil.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security.cert; + +import java.lang.invoke.MethodHandles; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for certificate-related operations, including extracting fields from the subject or + * issuer DN and SAN fields from X509 certificates. + */ +public class CertUtil { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + public static final String SUBJECT_DN_PREFIX = "subject.dn"; + public static final String ISSUER_DN_PREFIX = "issuer.dn"; + public static final String SAN_PREFIX = "san."; + + /** + * Extracts a specified field or the entire DN from an X500Principal, such as a certificate's + * subject or issuer. If the entire DN is returned the format would be RFC2253 + * + * @param principal The X500Principal from which to extract information. + * @param path The DN field to extract, or a prefix indicating the entire DN. + * @return The value of the specified field, or the entire DN if just a prefix is provided. + */ + public static Optional extractFieldFromX500Principal( + X500Principal principal, String path) { + if (principal == null || path == null || path.isEmpty()) { + return Optional.empty(); + } + + String dn = principal.getName(X500Principal.RFC2253); + + try { + final LdapName ln = new LdapName(dn); + // Path does not specify a field, return the whole DN after normalizing it + if (path.equalsIgnoreCase(SUBJECT_DN_PREFIX) || path.equalsIgnoreCase(ISSUER_DN_PREFIX)) { + // Remove whitespaces around RDNs, sort them, and reconstruct the DN string + List rdns = + ln.getRdns().stream() + .map(rdn -> rdn.getType().trim() + "=" + rdn.getValue().toString().trim()) + .sorted() + .collect(Collectors.toList()); + dn = String.join(",", rdns); + return Optional.of(dn); + } + + // Extract and return the specified DN field value + String field = null; + if (path.startsWith(SUBJECT_DN_PREFIX + ".")) { + field = path.substring((SUBJECT_DN_PREFIX + ".").length()); + } else if (path.startsWith(ISSUER_DN_PREFIX + ".")) { + field = path.substring((ISSUER_DN_PREFIX + ".").length()); + } + + if (field != null) { + String fieldF = field; + return ln.getRdns().stream() + .filter(rdn -> rdn.getType().equalsIgnoreCase(fieldF)) + .findFirst() + .map(Rdn::getValue) + .map(Object::toString); + } + } catch (InvalidNameException e) { + log.warn("Invalid DN in LdapName instantiation. DN={}", dn); + } + return Optional.empty(); + } + + /** + * Extracts a specified field or the entire subject DN from an X509 certificate. + * + * @param certificate The certificate from which to extract the subject DN information. + * @param path The path specifying the subject DN field to extract or a prefix for the entire DN. + * @return An Optional containing the value of the specified subject DN field or the entire DN; + * empty if not found. + */ + public static Optional extractFromSubjectDN(X509Certificate certificate, String path) { + return extractFieldFromX500Principal(certificate.getSubjectX500Principal(), path); + } + + /** + * Extracts a specified field or the entire issuer DN from an X509 certificate. + * + * @param certificate The certificate from which to extract the issuer DN information. + * @param path The path specifying the issuer DN field to extract or a prefix for the entire DN. + * @return An Optional containing the value of the specified issuer DN field or the entire DN; + * empty if not found. + */ + public static Optional extractFromIssuerDN(X509Certificate certificate, String path) { + return extractFieldFromX500Principal(certificate.getIssuerX500Principal(), path); + } + + /** + * Extracts SAN (Subject Alternative Name) fields from an X509 certificate that match a specified + * path and predicate. + * + * @param certificate The certificate from which to extract SAN information. + * @param path The path specifying the SAN field to extract. + * @param valueMatcher A predicate to apply to each SAN value for filtering. + * @return An Optional containing a list of SAN values that match the specified path and + * predicate; empty if none found. + * @throws CertificateParsingException If an error occurs while parsing the certificate for SAN + * fields. + */ + public static Optional> extractFromSAN( + X509Certificate certificate, String path, Predicate valueMatcher) + throws CertificateParsingException { + Collection> altNames = certificate.getSubjectAlternativeNames(); + if (altNames == null) { + return Optional.empty(); + } + List filteredSANValues = + altNames.stream() + .filter(entry -> entry != null && entry.size() >= 2) + .filter( + entry -> { + SANType sanType = SANType.fromValue((Integer) entry.get(0)); + return sanType != null && sanType.pathLowerCase().equals(path); + }) + .map(entry -> (String) entry.get(1)) + .filter(valueMatcher) + .collect(Collectors.toList()); + + return Optional.of(filteredSANValues); + } + + /** + * Supported SAN (Subject Alternative Name) types as defined in RFC 5280 + */ + public enum SANType { + OTHER_NAME(0), // OtherName + EMAIL(1), // rfc822Name + DNS(2), // dNSName + X400_ADDRESS(3), // x400Address + DIRECTORY_NAME(4), // directoryName + EDI_PARTY_NAME(5), // ediPartyName + URI(6), // uniformResourceIdentifier + IP_ADDRESS(7), // iPAddress + REGISTERED_ID(8); // registeredID + + private static final Map lookup = + EnumSet.allOf(SANType.class).stream() + .collect(Collectors.toMap(SANType::getValue, sanType -> sanType)); + + private final int value; + + SANType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static SANType fromValue(int value) { + return lookup.get(value); + } + + public String path() { + return "SAN." + name(); + } + + public String pathLowerCase() { + return path().toLowerCase(Locale.ROOT); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java new file mode 100644 index 00000000000..043dd4303ce --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertPrincipalResolver.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security.cert; + +import java.lang.invoke.MethodHandles; +import java.security.Principal; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import javax.net.ssl.SSLPeerUnverifiedException; +import org.apache.http.auth.BasicUserPrincipal; +import org.apache.solr.common.SolrException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implements a {@link CertPrincipalResolver} that resolves a {@link Principal} from an X509 + * certificate based on configurable paths, filters, and optional extraction patterns. This resolver + * can extract principal information from various certificate fields, such as Subject DN or SAN + * fields, according to the specified path. Additionally, it can further refine the extracted value + * based on optional "after" and "before" patterns, allowing for more precise control over the + * principal value. + * + *

Example configuration without extraction pattern: + * + *

{@code
+ * "principalResolver": {
+ *   "class":"solr.PathBasedCertPrincipalResolver",
+ *   "params": {
+ *     "path":"SAN.email",
+ *     "filter":{
+ *       "checkType":"startsWith",
+ *       "values":["user@example"]
+ *     }
+ *   }
+ * }
+ * }
+ * + * In this configuration, the resolver is directed to extract email addresses from the SAN (Subject + * Alternative Name) field of the certificate and use them as principal names if they match the + * specified filter criteria. + * + *

Example configuration with extraction pattern: + * + *

{@code
+ * "principalResolver": {
+ *   "class":"solr.PathBasedCertPrincipalResolver",
+ *   "params": {
+ *     "path":"SAN.email",
+ *     "filter":{
+ *       "checkType":"startsWith",
+ *       "values":["email_user1@example"]
+ *     },
+ *     "extract": {
+ *       "after":"_",
+ *       "before":"@"
+ *     }
+ *   }
+ * }
+ * }
+ * + * In this extended configuration, after extracting email addresses that match the filter criteria, + * the resolver further processes the extracted value to include only the portion after the "_" + * symbol and before "@". This allows for extracting specific parts of the principal value, + * providing additional flexibility and control. + */ +public class PathBasedCertPrincipalResolver extends PathBasedCertResolverBase + implements CertPrincipalResolver { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String PARAM_EXTRACT = "extract"; + private static final String PARAM_AFTER = "after"; + private static final String PARAM_BEFORE = "before"; + + private final CertResolverPattern pattern; + private final String startPattern; + private final String endPattern; + + /** + * Constructs a new PathBasedCertPrincipalResolver with the specified configuration parameters. + * + * @param params The configuration parameters specifying the path and filter for extracting + * principal information from certificates. + */ + @SuppressWarnings("unchecked") + public PathBasedCertPrincipalResolver(Map params) { + this.pattern = createCertResolverPattern(params, CertUtil.SUBJECT_DN_PREFIX); + Map extractConfig = + (Map) params.getOrDefault(PARAM_EXTRACT, Collections.emptyMap()); + this.startPattern = extractConfig.getOrDefault(PARAM_AFTER, ""); + this.endPattern = extractConfig.getOrDefault(PARAM_BEFORE, ""); + } + + /** + * Resolves the principal from the given X509 certificate based on the configured path and filter. + * The first matching value, if any, is used as the principal name. + * + * @param certificate The X509Certificate from which to resolve the principal. + * @return A {@link Principal} object representing the resolved principal from the certificate. + * @throws SSLPeerUnverifiedException If the SSL peer is not verified. + * @throws CertificateParsingException If parsing the certificate fails. + */ + @Override + public Principal resolvePrincipal(X509Certificate certificate) + throws SSLPeerUnverifiedException, CertificateParsingException { + Map> matches = + getValuesFromPaths(certificate, Collections.singletonList(pattern)); + String basePrincipal = null; + if (matches != null && !matches.isEmpty()) { + Set fieldValues = matches.getOrDefault(pattern.getName(), Collections.emptySet()); + basePrincipal = fieldValues.stream().findFirst().orElse(null); + } + + log.debug("Resolved basePrincipal: {}", basePrincipal); + if (basePrincipal == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Resolved principal is null. " + + "No principal information was found matching the configuration"); + } + + String principal = extractPrincipal(basePrincipal); + log.info("Resolved principal: {}", principal); + return new BasicUserPrincipal(principal); + } + + private String extractPrincipal(String str) { + int start = + startPattern.isEmpty() || !str.contains(startPattern) + ? 0 + : str.indexOf(startPattern) + startPattern.length(); + int end = endPattern.isEmpty() ? str.length() : str.indexOf(endPattern, start); + if (start >= 0 && end > start && end <= str.length()) { + str = str.substring(start, end); + } + return str; + } +} diff --git a/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java new file mode 100644 index 00000000000..486a1193b8b --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/PathBasedCertResolverBase.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security.cert; + +import static org.apache.solr.security.cert.CertResolverPattern.matchesPattern; +import static org.apache.solr.security.cert.CertUtil.ISSUER_DN_PREFIX; +import static org.apache.solr.security.cert.CertUtil.SAN_PREFIX; +import static org.apache.solr.security.cert.CertUtil.SUBJECT_DN_PREFIX; + +import java.lang.invoke.MethodHandles; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract base class for implementing path-based certificate resolvers. This class provides common + * functionality for extracting and processing certificate information based on configurable paths + * and filters. + */ +public abstract class PathBasedCertResolverBase { + + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private static final String PARAM_NAME = "name"; + private static final String PARAM_PATH = "path"; + private static final String PARAM_FILTER = "filter"; + private static final String PARAM_FILTER_CHECK_TYPE = "checkType"; + private static final String PARAM_FILTER_VALUES = "values"; + + /** + * Creates a {@link CertResolverPattern} from a given configuration map and a default path. The + * pattern defines how certificate information should be extracted and processed. + * + * @param config The configuration map containing resolver settings. + * @param defaultPath The default path to use if none is specified in the configuration. + * @return A new instance of {@link CertResolverPattern} based on the provided configuration. + */ + @SuppressWarnings("unchecked") + protected CertResolverPattern createCertResolverPattern( + Map config, String defaultPath) { + String path = ((String) config.getOrDefault(PARAM_PATH, defaultPath)).toLowerCase(Locale.ROOT); + String name = ((String) config.getOrDefault(PARAM_NAME, path)).toLowerCase(Locale.ROOT); + Map filter = + (Map) config.getOrDefault(PARAM_FILTER, Collections.emptyMap()); + String checkType = + (String) + filter.getOrDefault( + PARAM_FILTER_CHECK_TYPE, CertResolverPattern.CheckType.WILDCARD.toString()); + List values = + (List) filter.getOrDefault(PARAM_FILTER_VALUES, Collections.emptyList()); + Set lowerCaseValues = + values.stream().map(value -> value.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + return new CertResolverPattern(name, path, checkType, lowerCaseValues); + } + + /** + * Extracts values from specified paths in a certificate and organizes them into a map. The map's + * keys are derived from the names specified in the patterns, and the values are sets of strings + * extracted from the certificate according to the pattern definitions. + * + * @param certificate The X509Certificate from which to extract information. + * @param patterns A list of {@link CertResolverPattern} objects defining the extraction criteria. + * @return A map where each key is a pattern name and each value is a set of extracted strings. + * @throws CertificateParsingException If an error occurs while parsing the certificate. + */ + protected Map> getValuesFromPaths( + X509Certificate certificate, List patterns) + throws CertificateParsingException { + Map> fieldValuesMap = new HashMap<>(); + for (CertResolverPattern pattern : patterns) { + String path = pattern.getPath(); + if (path.startsWith(SUBJECT_DN_PREFIX) || path.startsWith(ISSUER_DN_PREFIX)) { + Optional value = + path.startsWith(SUBJECT_DN_PREFIX) + ? CertUtil.extractFromSubjectDN(certificate, path) + : CertUtil.extractFromIssuerDN(certificate, path); + value.ifPresent( + val -> + fieldValuesMap + .computeIfAbsent(pattern.getName(), k -> new LinkedHashSet<>()) + .add(val)); + } else if (path.startsWith(SAN_PREFIX)) { + Optional> sanValues = + CertUtil.extractFromSAN(certificate, path, value -> matchesPattern(value, pattern)); + sanValues.ifPresent( + values -> + fieldValuesMap + .computeIfAbsent(pattern.getName(), k -> new LinkedHashSet<>()) + .addAll(values)); + } else { + throw new IllegalArgumentException( + "Invalid path in the certificate resolver pattern: " + path); + } + } + log.debug("Extracted field values: {}", fieldValuesMap); + return fieldValuesMap; + } +} diff --git a/solr/core/src/java/org/apache/solr/security/cert/package-info.java b/solr/core/src/java/org/apache/solr/security/cert/package-info.java new file mode 100644 index 00000000000..ab4eed56c0f --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/cert/package-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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. + */ + +/** Certificate based authentication classes */ +package org.apache.solr.security.cert; diff --git a/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java b/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java index c961b1775b8..b42ce94dcd1 100644 --- a/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java +++ b/solr/core/src/test/org/apache/solr/security/CertAuthPluginTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.when; import java.security.cert.X509Certificate; +import java.util.Collections; import javax.security.auth.x500.X500Principal; import javax.servlet.FilterChain; import javax.servlet.http.HttpServletRequest; @@ -46,6 +47,7 @@ public static void setupMockito() { public void setUp() throws Exception { super.setUp(); plugin = new CertAuthPlugin(); + plugin.init(Collections.emptyMap()); } @Test diff --git a/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java b/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java new file mode 100644 index 00000000000..1911ff66d6b --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/PathBasedCertPrincipalResolverTest.java @@ -0,0 +1,392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.solr.security; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import java.security.Principal; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.security.auth.x500.X500Principal; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.apache.solr.security.cert.CertResolverPattern; +import org.apache.solr.security.cert.CertUtil.SANType; +import org.apache.solr.security.cert.PathBasedCertPrincipalResolver; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(RandomizedRunner.class) +public class PathBasedCertPrincipalResolverTest extends SolrTestCaseJ4 { + + private static final String SUBJECT_DN = + " CN=John Doe, O=Solr Corp, OU= Engineering, C=US , ST =California, L =San Francisco"; // whitespaces should be ignored + private static final String ISSUER_DN = + "CN = Issuer, O= Issuer Corp, OU =IT, C=US , ST=California, L =San Francisco "; + + private X509Certificate mockCertificate; + + @BeforeClass + public static void setupMockito() { + SolrTestCaseJ4.assumeWorkingMockito(); + } + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + mockCertificate = mock(X509Certificate.class); + } + + @Test + public void testSubjectDn() throws SSLPeerUnverifiedException, CertificateParsingException { + // CN + testCertificateCases(SUBJECT_DN, "subject.dn.CN", "John Doe"); + + // O + testCertificateCases(SUBJECT_DN, "subject.dn.O", "Solr Corp"); + + // OU + testCertificateCases(SUBJECT_DN, "subject.dn.OU", "Engineering"); + + // C + testCertificateCases(SUBJECT_DN, "subject.dn.C", "US"); + + // ST + testCertificateCases(SUBJECT_DN, "subject.dn.ST", "California"); + + // L + testCertificateCases(SUBJECT_DN, "subject.dn.L", "San Francisco"); + } + + @Test + public void testIssuerDn() throws SSLPeerUnverifiedException, CertificateParsingException { + // CN + testCertificateCases(ISSUER_DN, "issuer.dn.CN", "Issuer"); + + // O + testCertificateCases(ISSUER_DN, "issuer.dn.O", "Issuer Corp"); + + // OU + testCertificateCases(ISSUER_DN, "issuer.dn.OU", "IT"); + + // C + testCertificateCases(ISSUER_DN, "issuer.dn.C", "US"); + + // ST + testCertificateCases(ISSUER_DN, "issuer.dn.ST", "California"); + + // L + testCertificateCases(ISSUER_DN, "issuer.dn.L", "San Francisco"); + } + + @Test + public void testSan() { + // Email + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com")), + "san.email", + "user1@example.com"); + + // Email with 'extract' + testCertificateCases( + List.of(List.of(SANType.EMAIL.getValue(), "user@example.com")), + null, + "san.email", + List.of("user@example.com"), + "startsWith", + Map.of("after", "@", "before", ".com"), + "example"); + testCertificateCases( + List.of(List.of(SANType.EMAIL.getValue(), "user@example.com")), + null, + "san.email", + List.of("user@example.com"), + "equals", + Map.of("before", "@"), + "user"); + + // DNS + testCertificateCases( + List.of( + List.of(SANType.DNS.getValue(), "value1.example.com"), + List.of(SANType.DNS.getValue(), "value2.example.com")), + "san.dns", + "value1.example.com"); + + // URI + testCertificateCases( + List.of( + List.of(SANType.URI.getValue(), "http://example.com"), + List.of(SANType.URI.getValue(), "http://example2.org")), + "san.uri", + List.of("http://example.com"), + "equals", + "http://example.com"); + + // IP Address + testCertificateCases( + List.of( + List.of(SANType.IP_ADDRESS.getValue(), "192.168.1.1"), + List.of(SANType.IP_ADDRESS.getValue(), "10.0.0.1")), + "san.IP_ADDRESS", + "192.168.1.1"); + + // OTHER_NAME + testCertificateCases( + List.of(List.of(SANType.OTHER_NAME.getValue(), "1.2.3.4")), "san.OTHER_NAME", "1.2.3.4"); + + // X400_ADDRESS + testCertificateCases( + List.of(List.of(SANType.X400_ADDRESS.getValue(), "X400AddressValue")), + "san.X400_ADDRESS", + "X400AddressValue"); + + // DIRECTORY_NAME + testCertificateCases( + List.of(List.of(SANType.DIRECTORY_NAME.getValue(), "DirectoryNameValue")), + "san.DIRECTORY_NAME", + "DirectoryNameValue"); + + // EDI_PARTY_NAME + testCertificateCases( + List.of(List.of(SANType.EDI_PARTY_NAME.getValue(), "EdiPartyNameValue")), + "san.EDI_PARTY_NAME", + "EdiPartyNameValue"); + + // REGISTERED_ID + testCertificateCases( + List.of(List.of(SANType.REGISTERED_ID.getValue(), "RegisteredIdValue")), + "san.REGISTERED_ID", + "RegisteredIdValue"); + + // CheckType tests + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com")), + "san.email", + List.of("user2@example.com"), + "equals", + "user2@example.com"); + + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com")), + "san.email", + List.of("user2"), + "contains", + "user2@example.com"); + + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com")), + "san.email", + List.of("user2"), + "startsWith", + "user2@example.com"); + + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user@example1.com"), + List.of(SANType.EMAIL.getValue(), "user@example2.com")), + "san.email", + List.of("example2.com"), + "endsWith", + "user@example2.com"); + + testCertificateCases( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com")), + "san.email", + Collections.emptyList(), + "*", + "user1@example.com"); + } + + @Test + public void testMultipleSANEntries() + throws SSLPeerUnverifiedException, CertificateParsingException { + + when(mockCertificate.getSubjectAlternativeNames()) + .thenReturn( + List.of( + List.of(SANType.EMAIL.getValue(), "user1@example.com"), + List.of(SANType.EMAIL.getValue(), "user2@example.com"))); + + Map params = + Map.of( + "path", + "SAN.email", + "filter", + Map.of( + "checkType", + "equals", + "values", + List.of("user1@example.com", "user2@example.com"))); + + PathBasedCertPrincipalResolver resolver = new PathBasedCertPrincipalResolver(params); + Principal result = resolver.resolvePrincipal(mockCertificate); + + assertNotNull(result); + assertTrue( + result.getName().contains("user1@example.com") + || result.getName().contains("user2@example.com")); + } + + @Test + public void testResolverWithInvalidPath() { + Map params = + Map.of( + "path", + "Invalid.path", + "filter", + Map.of("checkType", "equals", "values", List.of("value1", "value2"))); + + assertThrows( + IllegalArgumentException.class, + () -> new PathBasedCertPrincipalResolver(params).resolvePrincipal(mockCertificate)); + } + + @Test + public void testNoMatchFound() { + Map params = + Map.of( + "path", + "subject.dn.CN", + "filter", + Map.of("checkType", "equals", "values", List.of("NonExistent"))); + + PathBasedCertPrincipalResolver resolver = new PathBasedCertPrincipalResolver(params); + assertThrows(SolrException.class, () -> resolver.resolvePrincipal(mockCertificate)); + } + + @Test + public void testResolverWithExtractPatternMissing() + throws SSLPeerUnverifiedException, CertificateParsingException { + when(mockCertificate.getSubjectAlternativeNames()) + .thenReturn(List.of(List.of(SANType.EMAIL.getValue(), "info@example.com"))); + + // Both 'after' and 'before' patterns are missing + final Map params = new HashMap<>(); + params.put("path", "SAN.email"); + params.put("filter", Map.of("checkType", "startsWith", "values", List.of("info@"))); + assertResolvedPrincipal(params, "info@example.com"); + + // Only 'before' pattern is provided, 'after' is missing + params.put("extract", Map.of("before", ".com")); + assertResolvedPrincipal(params, "info@example"); + + // Only 'after' pattern is provided, 'before' is missing + params.put("extract", Map.of("after", "@")); + assertResolvedPrincipal(params, "example.com"); + + // Invalid 'after' pattern that doesn't exist in the SAN value + params.put("extract", Map.of("after", "notfound")); + assertResolvedPrincipal( + params, + "info@example.com"); // Expect the original value since the 'after' pattern was not found + } + + private void testCertificateCases(List> sanData, String path, String expectedValue) { + + testCertificateCases( + sanData, + null, + path, + Collections.emptyList(), + CertResolverPattern.CheckType.WILDCARD.toString(), + Collections.emptyMap(), + expectedValue); + } + + private void testCertificateCases( + List> sanData, + String path, + List filterValues, + String filterCheckType, + String expectedValue) { + + testCertificateCases( + sanData, null, path, filterValues, filterCheckType, Collections.emptyMap(), expectedValue); + } + + private void testCertificateCases(String dn, String path, String expectedValue) { + + testCertificateCases( + null, + dn, + path, + Collections.emptyList(), + CertResolverPattern.CheckType.WILDCARD.toString(), + Collections.emptyMap(), + expectedValue); + } + + private void testCertificateCases( + List> sanData, + String dn, + String path, + List filterValues, + String filterCheckType, + Map extractPatterns, + String expectedValue) { + try { + if (path.startsWith("san")) { + when(mockCertificate.getSubjectAlternativeNames()).thenReturn(sanData); + } else if (path.startsWith("subject.dn")) { + X500Principal subjectDN = new X500Principal(dn); + when(mockCertificate.getSubjectX500Principal()).thenReturn(subjectDN); + } else if (path.startsWith("issuer.dn")) { + X500Principal issuerDN = new X500Principal(dn); + when(mockCertificate.getIssuerX500Principal()).thenReturn(issuerDN); + } + + Map params = new HashMap<>(); + params.put("path", path); + params.put("filter", Map.of("checkType", filterCheckType, "values", filterValues)); + params.put("extract", extractPatterns); + + assertResolvedPrincipal(params, expectedValue); + } catch (CertificateParsingException | SSLPeerUnverifiedException e) { + throw new RuntimeException(e); + } + } + + private void assertResolvedPrincipal(Map params, String expectedPrincipalName) + throws SSLPeerUnverifiedException, CertificateParsingException { + PathBasedCertPrincipalResolver resolver = new PathBasedCertPrincipalResolver(params); + Principal result = resolver.resolvePrincipal(mockCertificate); + + assertNotNull(result); + assertEquals(expectedPrincipalName, result.getName()); + } +}